From f8565c6dc45e453f6940bea7c23fc416b0642e74 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 17 Feb 2026 03:02:16 +0800 Subject: [PATCH 1/5] feat(ignore-files): add support for .traeignore and update related tests Enhanced the AIAgentIgnoreConfigFileInputPlugin and AIAgentIgnoreConfigFileOutputPlugin to include support for the new .traeignore file. Updated tests to reflect the addition, ensuring that the new ignore file is correctly processed and accounted for in the output. Adjusted the expected lengths and contents in various test cases to accommodate this change. --- ...AIAgentIgnoreConfigFileInputPlugin.test.ts | 21 ++++++--- .../AIAgentIgnoreConfigFileInputPlugin.ts | 2 +- ...IAgentIgnoreConfigFileOutputPlugin.test.ts | 46 +++++++++++++------ .../AIAgentIgnoreConfigFileOutputPlugin.ts | 33 +++++++++++-- 4 files changed, 74 insertions(+), 28 deletions(-) diff --git a/cli/src/plugins/AIAgentIgnoreConfigFileInputPlugin.test.ts b/cli/src/plugins/AIAgentIgnoreConfigFileInputPlugin.test.ts index 0c623549..c1583bb5 100644 --- a/cli/src/plugins/AIAgentIgnoreConfigFileInputPlugin.test.ts +++ b/cli/src/plugins/AIAgentIgnoreConfigFileInputPlugin.test.ts @@ -56,7 +56,7 @@ describe('aIAgentIgnoreConfigFileInputPlugin', () => { vi.mocked(fs.existsSync).mockImplementation((filePath: any) => { const fileName = path.basename(String(filePath)) - return ['.qoderignore', '.cursorignore', '.warpindexignore', '.aiignore', '.codeignore'].includes(fileName) + return ['.qoderignore', '.cursorignore', '.warpindexignore', '.aiignore', '.codeignore', '.traeignore'].includes(fileName) }) vi.mocked(fs.statSync).mockReturnValue({ @@ -71,13 +71,14 @@ describe('aIAgentIgnoreConfigFileInputPlugin', () => { if (fileName === '.warpindexignore') return 'warp ignore content' if (fileName === '.aiignore') return 'ai ignore content' if (fileName === '.codeignore') return 'windsurf code ignore content' + if (fileName === '.traeignore') return 'trae ignore content' return '' }) const result = plugin.collect(ctx) expect(result.aiAgentIgnoreConfigFiles).toBeDefined() - expect(result.aiAgentIgnoreConfigFiles).toHaveLength(5) + expect(result.aiAgentIgnoreConfigFiles).toHaveLength(6) expect(result.aiAgentIgnoreConfigFiles).toContainEqual({ fileName: '.qoderignore', content: 'qoder ignore content' @@ -98,6 +99,10 @@ describe('aIAgentIgnoreConfigFileInputPlugin', () => { fileName: '.codeignore', content: 'windsurf code ignore content' }) + expect(result.aiAgentIgnoreConfigFiles).toContainEqual({ + fileName: '.traeignore', + content: 'trae ignore content' + }) }) it('should read only existing ignore files', () => { @@ -151,13 +156,14 @@ describe('aIAgentIgnoreConfigFileInputPlugin', () => { const result = plugin.collect(ctx) - expect(result.aiAgentIgnoreConfigFiles).toHaveLength(5) + expect(result.aiAgentIgnoreConfigFiles).toHaveLength(6) expect(result.aiAgentIgnoreConfigFiles?.map(f => f.fileName)).toEqual([ '.cursorignore', '.kiroignore', '.warpindexignore', '.aiignore', - '.codeignore' + '.codeignore', + '.traeignore' ]) }) @@ -179,13 +185,14 @@ describe('aIAgentIgnoreConfigFileInputPlugin', () => { const result = plugin.collect(ctx) - expect(result.aiAgentIgnoreConfigFiles).toHaveLength(5) + expect(result.aiAgentIgnoreConfigFiles).toHaveLength(6) expect(result.aiAgentIgnoreConfigFiles?.map(f => f.fileName)).toEqual([ '.qoderignore', '.kiroignore', '.warpindexignore', '.aiignore', - '.codeignore' + '.codeignore', + '.traeignore' ]) expect(ctx.logger.warn).toHaveBeenCalled() }) @@ -204,7 +211,7 @@ describe('aIAgentIgnoreConfigFileInputPlugin', () => { plugin.collect(ctx) - expect(ctx.logger.debug).toHaveBeenCalledTimes(6) + expect(ctx.logger.debug).toHaveBeenCalledTimes(7) }) it('should support custom shadowSourceProjectDir', () => { diff --git a/cli/src/plugins/AIAgentIgnoreConfigFileInputPlugin.ts b/cli/src/plugins/AIAgentIgnoreConfigFileInputPlugin.ts index 6eeaf6d2..33df8b4a 100644 --- a/cli/src/plugins/AIAgentIgnoreConfigFileInputPlugin.ts +++ b/cli/src/plugins/AIAgentIgnoreConfigFileInputPlugin.ts @@ -9,7 +9,7 @@ import {AbstractInputPlugin} from './AbstractInputPlugin' /** * Ignore file names to read from shadow project dist directory */ -const IGNORE_FILE_NAMES = ['.qoderignore', '.cursorignore', '.kiroignore', '.warpindexignore', '.aiignore', '.codeignore'] as const +const IGNORE_FILE_NAMES = ['.qoderignore', '.cursorignore', '.kiroignore', '.warpindexignore', '.aiignore', '.codeignore', '.traeignore'] as const export class AIAgentIgnoreConfigFileInputPlugin extends AbstractInputPlugin { constructor() { diff --git a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.test.ts b/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.test.ts index a448e8c7..d67e415c 100644 --- a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.test.ts +++ b/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.test.ts @@ -43,7 +43,8 @@ describe('aIAgentIgnoreConfigFileOutputPlugin', () => { {fileName: '.cursorignore', content: 'cursor patterns'}, {fileName: '.kiroignore', content: 'kiro patterns'}, {fileName: '.warpindexignore', content: 'warp patterns'}, - {fileName: '.aiignore', content: 'ai patterns'} + {fileName: '.aiignore', content: 'ai patterns'}, + {fileName: '.traeignore', content: 'trae patterns'} ] } @@ -107,13 +108,14 @@ describe('aIAgentIgnoreConfigFileOutputPlugin', () => { const results = await plugin.registerProjectOutputFiles(ctx) - expect(results).toHaveLength(5) + expect(results).toHaveLength(6) expect(results.map(r => r.path)).toEqual([ path.join('project1', '.qoderignore'), path.join('project1', '.cursorignore'), path.join('project1', '.kiroignore'), path.join('project1', '.warpindexignore'), - path.join('project1', '.aiignore') + path.join('project1', '.aiignore'), + path.join('project1', '.trae', '.ignore') ]) }) @@ -122,13 +124,14 @@ describe('aIAgentIgnoreConfigFileOutputPlugin', () => { const results = await plugin.registerProjectOutputFiles(ctx) - expect(results).toHaveLength(5) // Should still register all known ignore file types for cleanup + expect(results).toHaveLength(6) // Should still register all known ignore file types for cleanup expect(results.map(r => r.path)).toEqual([ path.join('project1', '.qoderignore'), path.join('project1', '.cursorignore'), path.join('project1', '.kiroignore'), path.join('project1', '.warpindexignore'), - path.join('project1', '.aiignore') + path.join('project1', '.aiignore'), + path.join('project1', '.trae', '.ignore') ]) }) @@ -156,9 +159,10 @@ describe('aIAgentIgnoreConfigFileOutputPlugin', () => { const results = await plugin.registerProjectOutputFiles(ctx) - expect(results).toHaveLength(10) + expect(results).toHaveLength(12) expect(results.map(r => r.path)).toContain(path.join('project1', '.qoderignore')) expect(results.map(r => r.path)).toContain(path.join('project2', '.qoderignore')) + expect(results.map(r => r.path)).toContain(path.join('project1', '.trae', '.ignore')) }) it('should skip shadow source project since their ignore files are protected source files', async () => { @@ -186,7 +190,7 @@ describe('aIAgentIgnoreConfigFileOutputPlugin', () => { const results = await plugin.registerProjectOutputFiles(ctx) - expect(results).toHaveLength(5) // because prompt source project files are source files that should be protected // Should only register files for regular project, NOT prompt source project + expect(results).toHaveLength(6) // because prompt source project files are source files that should be protected // Should only register files for regular project, NOT prompt source project expect(results.map(r => r.path)).toContain(path.join('project1', '.qoderignore')) expect(results.map(r => r.path)).not.toContain(path.join('prompt-source-project', '.qoderignore')) }) @@ -218,9 +222,9 @@ describe('aIAgentIgnoreConfigFileOutputPlugin', () => { const results = await plugin.writeProjectOutputs(ctx) - expect(results.files).toHaveLength(5) + expect(results.files).toHaveLength(6) expect(results.files.every(r => r.success)).toBe(true) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(5) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(6) }) it('should write files to correct project paths', async () => { @@ -259,15 +263,25 @@ describe('aIAgentIgnoreConfigFileOutputPlugin', () => { 'ai patterns', 'utf8' ) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( + 6, + path.join(mockWorkspaceDir, 'project1', '.trae', '.ignore'), + 'trae patterns', + 'utf8' + ) }) - it('should not ensure directory exists (files written to project root)', async () => { + it('should ensure .trae directory exists when writing .trae/.ignore', async () => { const ignoreFiles = createMockIgnoreFiles() const ctx = createMockOutputWriteContext(ignoreFiles) await plugin.writeProjectOutputs(ctx) - expect(vi.mocked(fs.mkdirSync)).not.toHaveBeenCalled() // ensureDirectory should not be called since files are written to project root + expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledTimes(1) + expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledWith( + path.join(mockWorkspaceDir, 'project1', '.trae'), + {recursive: true} + ) }) it('should support dry-run mode', async () => { @@ -276,9 +290,10 @@ describe('aIAgentIgnoreConfigFileOutputPlugin', () => { const results = await plugin.writeProjectOutputs(ctx) - expect(results.files).toHaveLength(5) + expect(results.files).toHaveLength(6) expect(results.files.every(r => r.success && r.skipped === false)).toBe(true) expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() + expect(vi.mocked(fs.mkdirSync)).not.toHaveBeenCalled() }) it('should handle write errors gracefully', async () => { @@ -291,7 +306,7 @@ describe('aIAgentIgnoreConfigFileOutputPlugin', () => { const results = await plugin.writeProjectOutputs(ctx) - expect(results.files).toHaveLength(5) + expect(results.files).toHaveLength(6) expect(results.files[0].success).toBe(true) expect(results.files[1].success).toBe(false) expect(results.files[1].error).toBeDefined() @@ -357,8 +372,9 @@ describe('aIAgentIgnoreConfigFileOutputPlugin', () => { const results = await plugin.writeProjectOutputs(ctx) - expect(results.files).toHaveLength(10) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(10) + expect(results.files).toHaveLength(12) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(12) + expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledTimes(2) // .trae per project }) }) }) diff --git a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts b/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts index ca03c037..57b2cba9 100644 --- a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts +++ b/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts @@ -11,9 +11,23 @@ import {FilePathKind} from '@/types' import {AbstractOutputPlugin} from './AbstractOutputPlugin' /** - * All ignore file names that this plugin manages + * Input file name for trae → output path .trae/.ignore */ -const IGNORE_FILE_NAMES = ['.qoderignore', '.cursorignore', '.kiroignore', '.warpindexignore', '.aiignore'] as const +const TRAE_INPUT_FILE = '.traeignore' +const TRAE_OUTPUT_PATH = path.join('.trae', '.ignore') + +/** + * All output paths this plugin manages (for cleanup). + * Root-level ignore files + .trae/.ignore + */ +const CLEANUP_OUTPUT_PATHS = [ + '.qoderignore', + '.cursorignore', + '.kiroignore', + '.warpindexignore', + '.aiignore', + TRAE_OUTPUT_PATH +] as const export class AIAgentIgnoreConfigFileOutputPlugin extends AbstractOutputPlugin { constructor() { @@ -33,8 +47,8 @@ export class AIAgentIgnoreConfigFileOutputPlugin extends AbstractOutputPlugin { if (project.isPromptSourceProject === true) continue // that should be protected from cleanup // Skip prompt source projects (e.g., aindex) - their files are source files - for (const fileName of IGNORE_FILE_NAMES) { // Register all possible ignore files for cleanup - const filePath = path.join(project.dirFromWorkspacePath.path, fileName) + for (const outputPath of CLEANUP_OUTPUT_PATHS) { // Register all possible ignore output paths for cleanup + const filePath = path.join(project.dirFromWorkspacePath.path, outputPath) results.push({ pathKind: FilePathKind.Relative, path: filePath, @@ -87,13 +101,18 @@ export class AIAgentIgnoreConfigFileOutputPlugin extends AbstractOutputPlugin { return {files: fileResults, dirs: dirResults} } + private getOutputPath(fileName: string): string { + return fileName === TRAE_INPUT_FILE ? TRAE_OUTPUT_PATH : fileName + } + private async writeIgnoreFile( ctx: OutputWriteContext, projectDir: RelativePath, ignoreFile: {fileName: string, content: string}, label: string ): Promise { - const filePath = path.join(projectDir.path, ignoreFile.fileName) + const outputPath = this.getOutputPath(ignoreFile.fileName) + const filePath = path.join(projectDir.path, outputPath) const fullPath = path.join(projectDir.basePath, filePath) const relativePath: RelativePath = { @@ -110,6 +129,10 @@ export class AIAgentIgnoreConfigFileOutputPlugin extends AbstractOutputPlugin { } try { + if (outputPath === TRAE_OUTPUT_PATH) { + const traeDir = path.join(projectDir.basePath, projectDir.path, '.trae') + fs.mkdirSync(traeDir, {recursive: true}) + } fs.writeFileSync(fullPath, ignoreFile.content, 'utf8') this.log.trace({action: 'write', type: 'ignoreFile', path: fullPath, label}) return {path: relativePath, success: true} From 2e31defbece62525a41a6e0c4c0671a0c08c373f Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 17 Feb 2026 04:17:09 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20TraeIDE=20?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加 TraeIDEOutputPlugin 以支持将项目内存提示和快速命令输出为 Trae IDE 可用的 steering 和 rules 文件。该插件会为每个项目的子内存提示在 `.trae/rules/` 目录下生成对应的 Markdown 文件,并为全局内存和快速命令在 `.trae/steering/` 目录下生成文件。 --- cli/src/plugin.config.ts | 2 + cli/src/plugins/TraeIDEOutputPlugin.test.ts | 138 ++++++++++++++++++ cli/src/plugins/TraeIDEOutputPlugin.ts | 151 ++++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 cli/src/plugins/TraeIDEOutputPlugin.test.ts create mode 100644 cli/src/plugins/TraeIDEOutputPlugin.ts diff --git a/cli/src/plugin.config.ts b/cli/src/plugin.config.ts index cfd5964e..51d136f5 100644 --- a/cli/src/plugin.config.ts +++ b/cli/src/plugin.config.ts @@ -29,6 +29,7 @@ import {ShadowProjectInputPlugin} from '@/plugins/ShadowProjectInputPlugin' import {SkillInputPlugin} from '@/plugins/SkillInputPlugin' import {SkillNonSrcFileSyncEffectInputPlugin} from '@/plugins/SkillNonSrcFileSyncEffectInputPlugin' import {SubAgentInputPlugin} from '@/plugins/SubAgentInputPlugin' +import {TraeIDEOutputPlugin} from '@/plugins/TraeIDEOutputPlugin' import {VisualStudioCodeIDEConfigOutputPlugin} from '@/plugins/VisualStudioCodeIDEConfigOutputPlugin' import {WarpIDEOutputPlugin} from '@/plugins/WarpIDEOutputPlugin' import {WindsurfOutputPlugin} from '@/plugins/WindsurfOutputPlugin' @@ -48,6 +49,7 @@ export default defineConfig({ new KiroCLIOutputPlugin(), new OpencodeCLIOutputPlugin(), new QoderIDEPluginOutputPlugin(), + new TraeIDEOutputPlugin(), new WarpIDEOutputPlugin(), new WindsurfOutputPlugin(), new CursorOutputPlugin(), diff --git a/cli/src/plugins/TraeIDEOutputPlugin.test.ts b/cli/src/plugins/TraeIDEOutputPlugin.test.ts new file mode 100644 index 00000000..b28ae069 --- /dev/null +++ b/cli/src/plugins/TraeIDEOutputPlugin.test.ts @@ -0,0 +1,138 @@ +import type {FastCommandPrompt, OutputWriteContext, Project, ProjectChildrenMemoryPrompt, WriteResult} from '@/types' +import type {RelativePath} from '@/types/FileSystemTypes' +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' +import {FilePathKind, PromptKind} from '@/types' +import {TraeIDEOutputPlugin} from './TraeIDEOutputPlugin' + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { + return { + pathKind: FilePathKind.Relative, + path: pathStr, + basePath, + getDirectoryName: () => pathStr, + getAbsolutePath: () => `${basePath}/${pathStr}` + } +} + +function createMockFastCommandPrompt( + series: string | undefined, + commandName: string +): FastCommandPrompt { + return { + type: PromptKind.FastCommand, + series, + commandName, + content: '', + length: 0, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', '/test'), + markdownContents: [] + } as FastCommandPrompt +} + +class TestableTraeIDEOutputPlugin extends TraeIDEOutputPlugin { + private mockHomeDir: string | null = null + public capturedWriteFile: {path: string; content: string} | null = null + + public testBuildFastCommandSteeringFileName(cmd: FastCommandPrompt): string { + return (this as any).buildFastCommandSteeringFileName(cmd) + } + + public async testWriteSteeringFile(ctx: OutputWriteContext, project: Project, child: ProjectChildrenMemoryPrompt): Promise { + return (this as any).writeSteeringFile(ctx, project, child) + } + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } + + protected override async writeFile(_ctx: OutputWriteContext, path: string, content: string): Promise { + this.capturedWriteFile = {path, content} + return {success: true, description: 'Mock write', filePath: path} + } +} + +describe('TraeIDEOutputPlugin', () => { + describe('buildFastCommandSteeringFileName', () => { + const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z0-9]+$/i.test(s)) + + const alphanumericCommandName = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^\w+$/.test(s)) + + it('should use hyphen separator between series and command name', () => { + fc.assert( + fc.property( + alphanumericNoUnderscore, + alphanumericCommandName, + (series, commandName) => { + const plugin = new TestableTraeIDEOutputPlugin() + const cmd = createMockFastCommandPrompt(series, commandName) + + const result = plugin.testBuildFastCommandSteeringFileName(cmd) + + expect(result).toBe(`${series}-${commandName}.md`) + } + ), + {numRuns: 100} + ) + }) + + it('should return just commandName.md when series is undefined', () => { + fc.assert( + fc.property( + alphanumericCommandName, + commandName => { + const plugin = new TestableTraeIDEOutputPlugin() + const cmd = createMockFastCommandPrompt(void 0, commandName) + + const result = plugin.testBuildFastCommandSteeringFileName(cmd) + + expect(result).toBe(`${commandName}.md`) + } + ), + {numRuns: 100} + ) + }) + }) + + describe('writeSteeringFile (Child Memory Prompts)', () => { + it('should write to .trae/rules with correct frontmatter', async () => { + const plugin = new TestableTraeIDEOutputPlugin() + const project = { + dirFromWorkspacePath: { + path: 'packages/pkg-a', + basePath: '/workspace' + } + } as any + const child = { + dir: { path: 'src/components' }, + workingChildDirectoryPath: { path: 'src/components' }, + content: 'child content' + } as any + const ctx = { + dryRun: false + } as any + + await plugin.testWriteSteeringFile(ctx, project, child) + + expect(plugin.capturedWriteFile).not.toBeNull() + const {path, content} = plugin.capturedWriteFile! + + // Verify path contains .trae/rules + expect(path.replaceAll('\\', '/')).toContain('/.trae/rules/') + + // Verify frontmatter + expect(content).toContain('---') + expect(content).toContain('alwaysApply: false') + expect(content).toContain('globs: src/components/**') + expect(content).toContain('child content') + }) + }) +}) diff --git a/cli/src/plugins/TraeIDEOutputPlugin.ts b/cli/src/plugins/TraeIDEOutputPlugin.ts new file mode 100644 index 00000000..c7500397 --- /dev/null +++ b/cli/src/plugins/TraeIDEOutputPlugin.ts @@ -0,0 +1,151 @@ +import type { + FastCommandPrompt, + OutputPluginContext, + OutputWriteContext, + Project, + ProjectChildrenMemoryPrompt, + WriteResult, + WriteResults +} from '@/types' +import type {RelativePath} from '@/types/FileSystemTypes' +import {AbstractOutputPlugin} from './AbstractOutputPlugin' + +const GLOBAL_MEMORY_FILE = 'GLOBAL.md' +const GLOBAL_CONFIG_DIR = '.trae' +const STEERING_SUBDIR = 'steering' +const RULES_SUBDIR = 'rules' + +export class TraeIDEOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('TraeIDEOutputPlugin', {globalConfigDir: GLOBAL_CONFIG_DIR, outputFileName: GLOBAL_MEMORY_FILE}) + } + + private getGlobalSteeringDir(): string { + return this.joinPath(this.getGlobalConfigDir(), STEERING_SUBDIR) + } + + async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + return projects + .filter(p => p.dirFromWorkspacePath != null) + .map(p => this.createRelativePath( + this.joinPath(p.dirFromWorkspacePath!.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR), + p.dirFromWorkspacePath!.basePath, + () => RULES_SUBDIR + )) + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + const results: RelativePath[] = [] + + for (const project of projects) { + if (project.dirFromWorkspacePath == null || project.childMemoryPrompts == null) continue + for (const child of project.childMemoryPrompts) { + results.push(this.createRelativePath( + this.joinPath(project.dirFromWorkspacePath.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR, this.buildSteeringFileName(child)), + project.dirFromWorkspacePath.basePath, + () => RULES_SUBDIR + )) + } + } + return results + } + + async registerGlobalOutputDirs(): Promise { + return [ + this.createRelativePath(STEERING_SUBDIR, this.getGlobalConfigDir(), () => STEERING_SUBDIR) + ] + } + + async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { + const {globalMemory, fastCommands} = ctx.collectedInputContext + const steeringDir = this.getGlobalSteeringDir() + const results: RelativePath[] = [] + + if (globalMemory != null) results.push(this.createRelativePath(GLOBAL_MEMORY_FILE, steeringDir, () => STEERING_SUBDIR)) + + if (fastCommands != null) { + for (const cmd of fastCommands) results.push(this.createRelativePath(this.buildFastCommandSteeringFileName(cmd), steeringDir, () => STEERING_SUBDIR)) + } + + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {workspace, globalMemory, fastCommands} = ctx.collectedInputContext + const hasChildPrompts = workspace.projects.some(p => (p.childMemoryPrompts?.length ?? 0) > 0) + if (hasChildPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0) return true + this.log.trace({action: 'skip', reason: 'noOutputs'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + const fileResults: WriteResult[] = [] + + for (const project of projects) { + if (project.dirFromWorkspacePath == null || project.childMemoryPrompts == null) continue + for (const child of project.childMemoryPrompts) fileResults.push(await this.writeSteeringFile(ctx, project, child)) + } + return {files: fileResults, dirs: []} + } + + async writeGlobalOutputs(ctx: OutputWriteContext): Promise { + const {globalMemory, fastCommands} = ctx.collectedInputContext + const fileResults: WriteResult[] = [] + const steeringDir = this.getGlobalSteeringDir() + + if (globalMemory != null) { + fileResults.push(await this.writeFile(ctx, this.joinPath(steeringDir, GLOBAL_MEMORY_FILE), globalMemory.content as string, 'globalMemory')) + } + + if (fastCommands != null) { + for (const cmd of fastCommands) fileResults.push(await this.writeFastCommandSteeringFile(ctx, cmd)) + } + + return {files: fileResults, dirs: []} + } + + private buildFastCommandSteeringFileName(cmd: FastCommandPrompt): string { + return this.transformFastCommandName(cmd, {includeSeriesPrefix: true, seriesSeparator: '-'}) + } + + private async writeFastCommandSteeringFile(ctx: OutputWriteContext, cmd: FastCommandPrompt): Promise { + const fileName = this.buildFastCommandSteeringFileName(cmd) + const fullPath = this.joinPath(this.getGlobalSteeringDir(), fileName) + const desc = cmd.yamlFrontMatter?.description + const content = this.buildMarkdownContent(cmd.content, { + inclusion: 'manual', + description: desc != null && desc.length > 0 ? desc : null + }) + return this.writeFile(ctx, fullPath, content, 'fastCommandSteering') + } + + private buildSteeringFileName(child: ProjectChildrenMemoryPrompt): string { + const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path + const normalized = childPath.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '').replaceAll('/', '-') + return `trae-${normalized}.md` + } + + private async writeSteeringFile(ctx: OutputWriteContext, project: Project, child: ProjectChildrenMemoryPrompt): Promise { + const projectDir = project.dirFromWorkspacePath! + const fileName = this.buildSteeringFileName(child) + const targetDir = this.joinPath(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR) + const fullPath = this.joinPath(targetDir, fileName) + + const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path + const globPattern = `${childPath.replaceAll('\\', '/')}/**` + + const content = [ + '---', + 'alwaysApply: false', + `globs: ${globPattern}`, + '---', + '', + child.content + ].join('\n') + + return this.writeFile(ctx, fullPath, content, 'steeringFile') + } +} From f35628792d5eb99614eab2dc13bce4f57baf4889 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 17 Feb 2026 20:12:42 +0800 Subject: [PATCH 3/5] test: auto-generated commit (1 added, 20 modified, 2 deleted) Co-authored-by: Cursor --- cli/src/plugin.config.ts | 2 - ...IAgentIgnoreConfigFileOutputPlugin.test.ts | 380 ------------------ .../AIAgentIgnoreConfigFileOutputPlugin.ts | 146 ------- cli/src/plugins/AbstractOutputPlugin.test.ts | 200 ++++++++- cli/src/plugins/AbstractOutputPlugin.ts | 90 +++++ .../plugins/ClaudeCodeCLIOutputPlugin.test.ts | 2 +- cli/src/plugins/CursorOutputPlugin.test.ts | 11 +- cli/src/plugins/CursorOutputPlugin.ts | 29 +- cli/src/plugins/DroidCLIOutputPlugin.test.ts | 2 +- .../plugins/GenericSkillsOutputPlugin.test.ts | 4 +- .../plugins/GitExcludeOutputPlugin.test.ts | 58 ++- cli/src/plugins/GitExcludeOutputPlugin.ts | 22 +- cli/src/plugins/KiroCLIOutputPlugin.ts | 14 +- cli/src/plugins/QoderIDEPluginOutputPlugin.ts | 10 +- .../ReadmeMdInputPlugin.property.test.ts | 2 +- cli/src/plugins/TraeIDEOutputPlugin.test.ts | 20 +- cli/src/plugins/TraeIDEOutputPlugin.ts | 13 +- cli/src/plugins/WarpIDEOutputPlugin.test.ts | 8 +- cli/src/plugins/WarpIDEOutputPlugin.ts | 37 +- cli/src/plugins/WindsurfOutputPlugin.ts | 13 +- .../JetBrainsAIAssistantCodexOutputPlugin.ts | 12 +- gui/package.json | 7 +- gui/scripts/run-tauri-tests.ts | 32 ++ 23 files changed, 474 insertions(+), 640 deletions(-) delete mode 100644 cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.test.ts delete mode 100644 cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts create mode 100644 gui/scripts/run-tauri-tests.ts diff --git a/cli/src/plugin.config.ts b/cli/src/plugin.config.ts index 51d136f5..8d6c5174 100644 --- a/cli/src/plugin.config.ts +++ b/cli/src/plugin.config.ts @@ -1,7 +1,6 @@ import {defineConfig} from '@/config' import {AgentsOutputPlugin} from '@/plugins/AgentsOutputPlugin' import {AIAgentIgnoreConfigFileInputPlugin} from '@/plugins/AIAgentIgnoreConfigFileInputPlugin' -import {AIAgentIgnoreConfigFileOutputPlugin} from '@/plugins/AIAgentIgnoreConfigFileOutputPlugin' import {AntigravityOutputPlugin} from '@/plugins/AntigravityOutputPlugin' import {ClaudeCodeCLIOutputPlugin} from '@/plugins/ClaudeCodeCLIOutputPlugin' import {CodexCLIOutputPlugin} from '@/plugins/CodexCLIOutputPlugin' @@ -38,7 +37,6 @@ import {WorkspaceInputPlugin} from '@/plugins/WorkspaceInputPlugin' export default defineConfig({ plugins: [ new AgentsOutputPlugin(), - new AIAgentIgnoreConfigFileOutputPlugin(), new AntigravityOutputPlugin(), new ClaudeCodeCLIOutputPlugin(), new CodexCLIOutputPlugin(), diff --git a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.test.ts b/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.test.ts deleted file mode 100644 index d67e415c..00000000 --- a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -import type { - AIAgentIgnoreConfigFile, - CollectedInputContext, - OutputPluginContext, - OutputWriteContext -} from '@/types' -import type {RelativePath} from '@/types/FileSystemTypes' -import fs from 'node:fs' -import path from 'node:path' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {FilePathKind} from '@/types' -import {AIAgentIgnoreConfigFileOutputPlugin} from './AIAgentIgnoreConfigFileOutputPlugin' - -vi.mock('node:fs') - -describe('aIAgentIgnoreConfigFileOutputPlugin', () => { - let plugin: AIAgentIgnoreConfigFileOutputPlugin - const mockWorkspaceDir = '/workspace' - - beforeEach(() => { - plugin = new AIAgentIgnoreConfigFileOutputPlugin() - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.mkdirSync).mockReturnValue(void 0) - vi.mocked(fs.writeFileSync).mockReturnValue(void 0) - vi.clearAllMocks() - }) - - afterEach(() => vi.clearAllMocks()) - - function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => path.basename(pathStr), - getAbsolutePath: () => path.join(basePath, pathStr) - } - } - - function createMockIgnoreFiles(): AIAgentIgnoreConfigFile[] { - return [ - {fileName: '.qoderignore', content: 'qoder patterns'}, - {fileName: '.cursorignore', content: 'cursor patterns'}, - {fileName: '.kiroignore', content: 'kiro patterns'}, - {fileName: '.warpindexignore', content: 'warp patterns'}, - {fileName: '.aiignore', content: 'ai patterns'}, - {fileName: '.traeignore', content: 'trae patterns'} - ] - } - - function createMockOutputPluginContext( - ignoreFiles: AIAgentIgnoreConfigFile[] = [] - ): OutputPluginContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir) - } - ] - }, - ideConfigFiles: [], - aiAgentIgnoreConfigFiles: ignoreFiles - } as CollectedInputContext - } - } - - function createMockOutputWriteContext( - ignoreFiles: AIAgentIgnoreConfigFile[] = [], - dryRun = false - ): OutputWriteContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir) - } - ] - }, - ideConfigFiles: [], - aiAgentIgnoreConfigFiles: ignoreFiles - } as CollectedInputContext, - dryRun - } - } - - describe('registerProjectOutputDirs', () => { - it('should return empty array', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx = createMockOutputPluginContext(ignoreFiles) - - const results = await plugin.registerProjectOutputDirs(ctx) - - expect(results).toHaveLength(0) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should register all ignore files for each project regardless of aiAgentIgnoreConfigFiles', async () => { - const ignoreFiles = createMockIgnoreFiles() // Even with ignore files provided, should register all known ignore file types - const ctx = createMockOutputPluginContext(ignoreFiles) - - const results = await plugin.registerProjectOutputFiles(ctx) - - expect(results).toHaveLength(6) - expect(results.map(r => r.path)).toEqual([ - path.join('project1', '.qoderignore'), - path.join('project1', '.cursorignore'), - path.join('project1', '.kiroignore'), - path.join('project1', '.warpindexignore'), - path.join('project1', '.aiignore'), - path.join('project1', '.trae', '.ignore') - ]) - }) - - it('should register all ignore files even when aiAgentIgnoreConfigFiles is empty', async () => { - const ctx = createMockOutputPluginContext([]) // This is the key fix: cleanup should work even without collected ignore files - - const results = await plugin.registerProjectOutputFiles(ctx) - - expect(results).toHaveLength(6) // Should still register all known ignore file types for cleanup - expect(results.map(r => r.path)).toEqual([ - path.join('project1', '.qoderignore'), - path.join('project1', '.cursorignore'), - path.join('project1', '.kiroignore'), - path.join('project1', '.warpindexignore'), - path.join('project1', '.aiignore'), - path.join('project1', '.trae', '.ignore') - ]) - }) - - it('should register files for multiple projects', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'project-1', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir) - }, - { - name: 'project-2', - dirFromWorkspacePath: createMockRelativePath('project2', mockWorkspaceDir) - } - ] - }, - ideConfigFiles: [], - aiAgentIgnoreConfigFiles: ignoreFiles - } as CollectedInputContext - } - - const results = await plugin.registerProjectOutputFiles(ctx) - - expect(results).toHaveLength(12) - expect(results.map(r => r.path)).toContain(path.join('project1', '.qoderignore')) - expect(results.map(r => r.path)).toContain(path.join('project2', '.qoderignore')) - expect(results.map(r => r.path)).toContain(path.join('project1', '.trae', '.ignore')) - }) - - it('should skip shadow source project since their ignore files are protected source files', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'regular-project', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir) - }, - { - name: 'prompt-source-project', - isPromptSourceProject: true, // to protect source files // Prompt source project (e.g., aindex) - should be skipped for cleanup - dirFromWorkspacePath: createMockRelativePath('prompt-source-project', mockWorkspaceDir) - } - ] - }, - ideConfigFiles: [], - aiAgentIgnoreConfigFiles: ignoreFiles - } as CollectedInputContext - } - - const results = await plugin.registerProjectOutputFiles(ctx) - - expect(results).toHaveLength(6) // because prompt source project files are source files that should be protected // Should only register files for regular project, NOT prompt source project - expect(results.map(r => r.path)).toContain(path.join('project1', '.qoderignore')) - expect(results.map(r => r.path)).not.toContain(path.join('prompt-source-project', '.qoderignore')) - }) - }) - - describe('canWrite', () => { - it('should return true when ignore files exist', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx = createMockOutputWriteContext(ignoreFiles) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(true) - }) - - it('should return false when no ignore files exist', async () => { - const ctx = createMockOutputWriteContext([]) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(false) - }) - }) - - describe('writeProjectOutputs', () => { - it('should write ignore files to project directories', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx = createMockOutputWriteContext(ignoreFiles) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(6) - expect(results.files.every(r => r.success)).toBe(true) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(6) - }) - - it('should write files to correct project paths', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx = createMockOutputWriteContext(ignoreFiles) - - await plugin.writeProjectOutputs(ctx) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( - 1, - path.join(mockWorkspaceDir, 'project1', '.qoderignore'), - 'qoder patterns', - 'utf8' - ) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( - 2, - path.join(mockWorkspaceDir, 'project1', '.cursorignore'), - 'cursor patterns', - 'utf8' - ) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( - 3, - path.join(mockWorkspaceDir, 'project1', '.kiroignore'), - 'kiro patterns', - 'utf8' - ) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( - 4, - path.join(mockWorkspaceDir, 'project1', '.warpindexignore'), - 'warp patterns', - 'utf8' - ) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( - 5, - path.join(mockWorkspaceDir, 'project1', '.aiignore'), - 'ai patterns', - 'utf8' - ) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( - 6, - path.join(mockWorkspaceDir, 'project1', '.trae', '.ignore'), - 'trae patterns', - 'utf8' - ) - }) - - it('should ensure .trae directory exists when writing .trae/.ignore', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx = createMockOutputWriteContext(ignoreFiles) - - await plugin.writeProjectOutputs(ctx) - - expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledTimes(1) - expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledWith( - path.join(mockWorkspaceDir, 'project1', '.trae'), - {recursive: true} - ) - }) - - it('should support dry-run mode', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx = createMockOutputWriteContext(ignoreFiles, true) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(6) - expect(results.files.every(r => r.success && r.skipped === false)).toBe(true) - expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() - expect(vi.mocked(fs.mkdirSync)).not.toHaveBeenCalled() - }) - - it('should handle write errors gracefully', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx = createMockOutputWriteContext(ignoreFiles) - - vi.mocked(fs.writeFileSync).mockImplementation((filePath: string) => { - if (filePath.includes('.cursorignore')) throw new Error('Permission denied') - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(6) - expect(results.files[0].success).toBe(true) - expect(results.files[1].success).toBe(false) - expect(results.files[1].error).toBeDefined() - expect(results.files[2].success).toBe(true) - }) - - it('should return empty results when no ignore files exist', async () => { - const ctx = createMockOutputWriteContext([]) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(0) - expect(results.dirs).toHaveLength(0) - expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() - }) - - it('should skip projects without dirFromWorkspacePath', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx: OutputWriteContext = { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project' - } - ] - }, - ideConfigFiles: [], - aiAgentIgnoreConfigFiles: ignoreFiles - } as CollectedInputContext, - dryRun: false - } - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(0) - expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() - }) - - it('should write files for multiple projects', async () => { - const ignoreFiles = createMockIgnoreFiles() - const ctx: OutputWriteContext = { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'project-1', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir) - }, - { - name: 'project-2', - dirFromWorkspacePath: createMockRelativePath('project2', mockWorkspaceDir) - } - ] - }, - ideConfigFiles: [], - aiAgentIgnoreConfigFiles: ignoreFiles - } as CollectedInputContext, - dryRun: false - } - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(12) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(12) - expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledTimes(2) // .trae per project - }) - }) -}) diff --git a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts b/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts deleted file mode 100644 index 57b2cba9..00000000 --- a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { - OutputPluginContext, - OutputWriteContext, - WriteResult, - WriteResults -} from '@/types' -import type {RelativePath} from '@/types/FileSystemTypes' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {FilePathKind} from '@/types' -import {AbstractOutputPlugin} from './AbstractOutputPlugin' - -/** - * Input file name for trae → output path .trae/.ignore - */ -const TRAE_INPUT_FILE = '.traeignore' -const TRAE_OUTPUT_PATH = path.join('.trae', '.ignore') - -/** - * All output paths this plugin manages (for cleanup). - * Root-level ignore files + .trae/.ignore - */ -const CLEANUP_OUTPUT_PATHS = [ - '.qoderignore', - '.cursorignore', - '.kiroignore', - '.warpindexignore', - '.aiignore', - TRAE_OUTPUT_PATH -] as const - -export class AIAgentIgnoreConfigFileOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('AIAgentIgnoreConfigFileOutputPlugin') - } - - async registerProjectOutputDirs(): Promise { - return [] // No directories to clean - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - - for (const project of projects) { - if (project.dirFromWorkspacePath == null) continue - - if (project.isPromptSourceProject === true) continue // that should be protected from cleanup // Skip prompt source projects (e.g., aindex) - their files are source files - - for (const outputPath of CLEANUP_OUTPUT_PATHS) { // Register all possible ignore output paths for cleanup - const filePath = path.join(project.dirFromWorkspacePath.path, outputPath) - results.push({ - pathKind: FilePathKind.Relative, - path: filePath, - basePath: project.dirFromWorkspacePath.basePath, - getDirectoryName: () => path.basename(project.dirFromWorkspacePath!.path), - getAbsolutePath: () => path.join(project.dirFromWorkspacePath!.basePath, filePath) - }) - } - } - - return results - } - - async registerGlobalOutputDirs(): Promise { - return [] // No global directories to clean - } - - async registerGlobalOutputFiles(): Promise { - return [] // No global files to clean - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {aiAgentIgnoreConfigFiles} = ctx.collectedInputContext - if (aiAgentIgnoreConfigFiles?.length !== 0) return true - - this.log.debug('skipped', {reason: 'no ignore config files to write'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const {projects} = ctx.collectedInputContext.workspace - const {aiAgentIgnoreConfigFiles} = ctx.collectedInputContext - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - if (aiAgentIgnoreConfigFiles == null || aiAgentIgnoreConfigFiles.length === 0) return {files: fileResults, dirs: dirResults} - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - - const projectName = project.name ?? 'unknown' - - for (const ignoreFile of aiAgentIgnoreConfigFiles) { - const result = await this.writeIgnoreFile(ctx, projectDir, ignoreFile, `project:${projectName}/${ignoreFile.fileName}`) - fileResults.push(result) - } - } - - return {files: fileResults, dirs: dirResults} - } - - private getOutputPath(fileName: string): string { - return fileName === TRAE_INPUT_FILE ? TRAE_OUTPUT_PATH : fileName - } - - private async writeIgnoreFile( - ctx: OutputWriteContext, - projectDir: RelativePath, - ignoreFile: {fileName: string, content: string}, - label: string - ): Promise { - const outputPath = this.getOutputPath(ignoreFile.fileName) - const filePath = path.join(projectDir.path, outputPath) - const fullPath = path.join(projectDir.basePath, filePath) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: filePath, - basePath: projectDir.basePath, - getDirectoryName: () => path.basename(projectDir.path), - getAbsolutePath: () => fullPath - } - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'ignoreFile', path: fullPath, label}) - return {path: relativePath, success: true, skipped: false} - } - - try { - if (outputPath === TRAE_OUTPUT_PATH) { - const traeDir = path.join(projectDir.basePath, projectDir.path, '.trae') - fs.mkdirSync(traeDir, {recursive: true}) - } - fs.writeFileSync(fullPath, ignoreFile.content, 'utf8') - this.log.trace({action: 'write', type: 'ignoreFile', path: fullPath, label}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'ignoreFile', path: fullPath, label, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } -} diff --git a/cli/src/plugins/AbstractOutputPlugin.test.ts b/cli/src/plugins/AbstractOutputPlugin.test.ts index 95912e7c..8780a0d7 100644 --- a/cli/src/plugins/AbstractOutputPlugin.test.ts +++ b/cli/src/plugins/AbstractOutputPlugin.test.ts @@ -1,4 +1,12 @@ -import type {FastCommandPrompt, OutputWriteContext, PluginOptions, RelativePath} from '../types' +import type { + AIAgentIgnoreConfigFile, + FastCommandPrompt, + OutputWriteContext, + PluginOptions, + Project, + RelativePath, + WriteResult +} from '../types' import type {FastCommandNameTransformOptions} from './AbstractOutputPlugin' import * as fc from 'fast-check' @@ -40,6 +48,14 @@ class TestOutputPlugin extends AbstractOutputPlugin { // Create a concrete test ) { return this.getTransformOptionsFromContext(ctx, additionalOptions) } + + public async testWriteProjectIgnoreFiles(ctx: OutputWriteContext): Promise { + return this.writeProjectIgnoreFiles(ctx) + } + + public testRegisterProjectIgnoreOutputFiles(projects: readonly Project[]): RelativePath[] { + return this.registerProjectIgnoreOutputFiles(projects) + } } function createMockRelativePath(pathStr: string, basePath: string): RelativePath { @@ -516,4 +532,186 @@ describe('abstractOutputPlugin', () => { expect(result.seriesSeparator).toBe('-') }) }) + + describe('indexignore helpers', () => { + function createIgnoreContext( + ignoreFileName: string | undefined, + projects: readonly Project[] + ): OutputWriteContext { + const collectedInputContext: any = { + workspace: { + directory: createMockRelativePath('.', '/test'), + projects + }, + ideConfigFiles: [], + aiAgentIgnoreConfigFiles: ignoreFileName == null + ? [] + : [{fileName: ignoreFileName, content: 'ignore patterns'}] + } + + return { + collectedInputContext, + dryRun: true + } as unknown as OutputWriteContext + } + + it('registerProjectIgnoreOutputFiles should return empty array when no indexignore is configured', () => { + const plugin = new TestOutputPlugin() + const projects: Project[] = [ + { + name: 'p1', + dirFromWorkspacePath: createMockRelativePath('project1', '/ws') + } as any + ] + + const results = plugin.testRegisterProjectIgnoreOutputFiles(projects) + expect(results).toHaveLength(0) + }) + + it('registerProjectIgnoreOutputFiles should register ignore file paths for each non-prompt project', () => { + const plugin = new TestOutputPlugin('IgnoreTestPlugin') + ;(plugin as any).indexignore = '.cursorignore' + + const projects: Project[] = [ + { + name: 'regular', + dirFromWorkspacePath: createMockRelativePath('project1', '/ws') + } as any, + { + name: 'prompt-src', + isPromptSourceProject: true, + dirFromWorkspacePath: createMockRelativePath('prompt-src', '/ws') + } as any + ] + + const results = plugin.testRegisterProjectIgnoreOutputFiles(projects) + const paths = results.map(r => r.path.replaceAll('\\', '/')) + expect(paths).toEqual(['project1/.cursorignore']) + }) + + it('writeProjectIgnoreFiles should write matching ignore file in dry-run mode', async () => { + const plugin = new TestOutputPlugin('IgnoreTestPlugin') + ;(plugin as any).indexignore = '.cursorignore' + + const projects: Project[] = [ + { + name: 'regular', + dirFromWorkspacePath: createMockRelativePath('project1', '/ws') + } as any + ] + + const ctx = createIgnoreContext('.cursorignore', projects) + const results = await plugin.testWriteProjectIgnoreFiles(ctx) + + expect(results).toHaveLength(1) + const first = results[0]! + expect(first.success).toBe(true) + expect(first.skipped).toBe(false) + expect(first.path.path.replaceAll('\\', '/')).toBe('project1/.cursorignore') + }) + + it('writeProjectIgnoreFiles should skip when no matching ignore file exists', async () => { + const plugin = new TestOutputPlugin('IgnoreTestPlugin') + ;(plugin as any).indexignore = '.cursorignore' + + const projects: Project[] = [ + { + name: 'regular', + dirFromWorkspacePath: createMockRelativePath('project1', '/ws') + } as any + ] + + const ctx = createIgnoreContext('.otherignore', projects) + const results = await plugin.testWriteProjectIgnoreFiles(ctx) + + expect(results).toHaveLength(0) + }) + + it('registerProjectIgnoreOutputFiles should never create entries for projects without dirFromWorkspacePath', () => { + fc.assert( + fc.property( + fc.array(fc.boolean(), {minLength: 0, maxLength: 5}), + flags => { + const plugin = new TestOutputPlugin('IgnoreTestPlugin') + ;(plugin as any).indexignore = '.cursorignore' + + const projects: Project[] = flags.map((hasDir, idx) => { + if (!hasDir) { + return { + name: `p${idx}` + } as Project + } + return { + name: `p${idx}`, + dirFromWorkspacePath: createMockRelativePath(`project${idx}`, '/ws') + } as Project + }) + + const results = plugin.testRegisterProjectIgnoreOutputFiles(projects) + const maxExpected = projects.filter( + p => p.dirFromWorkspacePath != null && p.isPromptSourceProject !== true + ).length + + expect(results.length).toBeLessThanOrEqual(maxExpected) + for (const r of results) expect(r.path.endsWith('.cursorignore')).toBe(true) + } + ), + {numRuns: 50} + ) + }) + + it('writeProjectIgnoreFiles should either write for all eligible projects or none, depending on presence of matching ignore file', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.boolean(), {minLength: 0, maxLength: 5}), + fc.boolean(), + async (hasDirFlags, includeMatchingIgnore) => { + const plugin = new TestOutputPlugin('IgnoreTestPlugin') + ;(plugin as any).indexignore = '.cursorignore' + + const projects: Project[] = hasDirFlags.map((hasDir, idx) => { + if (!hasDir) { + return { + name: `p${idx}` + } as Project + } + + const isPromptSourceProject = idx % 2 === 1 + return { + name: `p${idx}`, + dirFromWorkspacePath: createMockRelativePath(`project${idx}`, '/ws'), + isPromptSourceProject + } as Project + }) + + const ignoreFiles: AIAgentIgnoreConfigFile[] = includeMatchingIgnore + ? [{fileName: '.cursorignore', content: 'patterns'}] + : [{fileName: '.otherignore', content: 'other'}] + + const ctx: OutputWriteContext = { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', '/ws'), + projects + }, + ideConfigFiles: [], + aiAgentIgnoreConfigFiles: ignoreFiles + } as any, + dryRun: true + } as any + + const results = await plugin.testWriteProjectIgnoreFiles(ctx) + + const eligibleCount = projects.filter( + p => p.dirFromWorkspacePath != null && p.isPromptSourceProject !== true + ).length + + if (!includeMatchingIgnore || eligibleCount === 0) expect(results.length).toBe(0) + else expect(results.length).toBe(eligibleCount) + } + ), + {numRuns: 50} + ) + }) + }) }) diff --git a/cli/src/plugins/AbstractOutputPlugin.ts b/cli/src/plugins/AbstractOutputPlugin.ts index 7fff0b8e..cf361357 100644 --- a/cli/src/plugins/AbstractOutputPlugin.ts +++ b/cli/src/plugins/AbstractOutputPlugin.ts @@ -9,6 +9,7 @@ import type { OutputCleanContext, OutputPlugin, OutputWriteContext, + Project, RegistryOperationResult, WriteEffectHandler, WriteResult, @@ -54,6 +55,8 @@ export interface AbstractOutputPluginOptions { outputFileName?: string dependsOn?: readonly string[] + + indexignore?: string } /** @@ -72,6 +75,8 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin> = new Map() private readonly writeEffects: EffectRegistration[] = [] @@ -82,6 +87,7 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin path.basename(projectDir.path), + getAbsolutePath: () => path.join(projectDir.basePath, filePath) + }) + } + + return results + } + + protected async writeProjectIgnoreFiles(ctx: OutputWriteContext): Promise { + const outputPath = this.getIgnoreOutputPath() + if (outputPath == null) return [] + + const {workspace, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + const results: WriteResult[] = [] + + if (aiAgentIgnoreConfigFiles == null || aiAgentIgnoreConfigFiles.length === 0) return results + + const ignoreFile = aiAgentIgnoreConfigFiles.find(file => file.fileName === this.indexignore) + if (ignoreFile == null) return results + + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + if (project.isPromptSourceProject === true) continue + + const label = `project:${project.name ?? 'unknown'}/${ignoreFile.fileName}` + const filePath = path.join(projectDir.path, outputPath) + const fullPath = path.join(projectDir.basePath, filePath) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: filePath, + basePath: projectDir.basePath, + getDirectoryName: () => path.basename(projectDir.path), + getAbsolutePath: () => fullPath + } + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'ignoreFile', path: fullPath, label}) + results.push({path: relativePath, success: true, skipped: false}) + continue + } + + try { + if (outputPath === path.join('.trae', '.ignore')) { + const traeDir = path.join(projectDir.basePath, projectDir.path, '.trae') + fs.mkdirSync(traeDir, {recursive: true}) + } + fs.writeFileSync(fullPath, ignoreFile.content, 'utf8') + this.log.trace({action: 'write', type: 'ignoreFile', path: fullPath, label}) + results.push({path: relativePath, success: true}) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'ignoreFile', path: fullPath, label, error: errMsg}) + results.push({path: relativePath, success: false, error: error as Error}) + } + } + + return results + } + protected async writeFile( ctx: OutputWriteContext, fullPath: string, diff --git a/cli/src/plugins/ClaudeCodeCLIOutputPlugin.test.ts b/cli/src/plugins/ClaudeCodeCLIOutputPlugin.test.ts index 5114b4f4..66aca623 100644 --- a/cli/src/plugins/ClaudeCodeCLIOutputPlugin.test.ts +++ b/cli/src/plugins/ClaudeCodeCLIOutputPlugin.test.ts @@ -62,7 +62,7 @@ describe('claudeCodeCLIOutputPlugin', () => { path, glob: {} as any } - }) + }, 30000) afterEach(() => { if (tempDir && fs.existsSync(tempDir)) { diff --git a/cli/src/plugins/CursorOutputPlugin.test.ts b/cli/src/plugins/CursorOutputPlugin.test.ts index a638420c..a0235368 100644 --- a/cli/src/plugins/CursorOutputPlugin.test.ts +++ b/cli/src/plugins/CursorOutputPlugin.test.ts @@ -595,9 +595,14 @@ describe('cursor output plugin', () => { } as unknown as OutputPluginContext const files = await plugin.registerProjectOutputFiles(ctx) - expect(files.length).toBe(1) - expect(files[0].path).toBe(path.join('project-a', '.cursor', 'rules', 'global.mdc')) - expect(files[0].getAbsolutePath()).toBe(path.join(tempDir, 'project-a', '.cursor', 'rules', 'global.mdc')) + const paths = files.map(f => f.path.replaceAll('\\', '/')) + + expect(paths).toContain(path.join('project-a', '.cursor', 'rules', 'global.mdc').replaceAll('\\', '/')) + + const globalEntry = files.find(f => f.path.replaceAll('\\', '/') === 'project-a/.cursor/rules/global.mdc') + expect(globalEntry?.getAbsolutePath().replaceAll('\\', '/')).toBe( + path.join(tempDir, 'project-a', '.cursor', 'rules', 'global.mdc').replaceAll('\\', '/') + ) }) it('should not register project rules when globalMemory is null', async () => { diff --git a/cli/src/plugins/CursorOutputPlugin.ts b/cli/src/plugins/CursorOutputPlugin.ts index 5d22b80c..6cdc611c 100644 --- a/cli/src/plugins/CursorOutputPlugin.ts +++ b/cli/src/plugins/CursorOutputPlugin.ts @@ -44,7 +44,8 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { super('CursorOutputPlugin', { globalConfigDir: GLOBAL_CONFIG_DIR, outputFileName: '', - dependsOn: ['AgentsOutputPlugin'] + dependsOn: ['AgentsOutputPlugin'], + indexignore: '.cursorignore' }) this.registerCleanEffect('mcp-config-cleanup', async ctx => { @@ -216,18 +217,20 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { if (projectDir == null) continue results.push(this.createProjectRuleFileRelativePath(projectDir, GLOBAL_RULE_FILE)) } + results.push(...this.registerProjectIgnoreOutputFiles(workspace.projects)) return results } async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, skills, fastCommands, globalMemory} = ctx.collectedInputContext + const {workspace, skills, fastCommands, globalMemory, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasSkills = (skills?.length ?? 0) > 0 const hasFastCommands = (fastCommands?.length ?? 0) > 0 const hasGlobalRuleOutput = globalMemory != null && workspace.projects.some(p => p.dirFromWorkspacePath != null) + const hasCursorIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.cursorignore') ?? false - if (hasSkills || hasFastCommands || hasGlobalRuleOutput) return true + if (hasSkills || hasFastCommands || hasGlobalRuleOutput || hasCursorIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false @@ -266,15 +269,19 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { const fileResults: WriteResult[] = [] const dirResults: WriteResult[] = [] const {workspace, globalMemory} = ctx.collectedInputContext - if (globalMemory == null) return {files: fileResults, dirs: dirResults} - - const content = this.buildGlobalRuleContent(globalMemory.content as string) - for (const project of workspace.projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - const result = await this.writeProjectGlobalRule(ctx, project, content) - fileResults.push(result) + if (globalMemory != null) { + const content = this.buildGlobalRuleContent(globalMemory.content as string) + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + const result = await this.writeProjectGlobalRule(ctx, project, content) + fileResults.push(result) + } } + + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) + return {files: fileResults, dirs: dirResults} } diff --git a/cli/src/plugins/DroidCLIOutputPlugin.test.ts b/cli/src/plugins/DroidCLIOutputPlugin.test.ts index 53d78d46..f0f11519 100644 --- a/cli/src/plugins/DroidCLIOutputPlugin.test.ts +++ b/cli/src/plugins/DroidCLIOutputPlugin.test.ts @@ -62,7 +62,7 @@ describe('droidCLIOutputPlugin', () => { path, glob: {} as any } - }) + }, 30000) afterEach(() => { if (tempDir && fs.existsSync(tempDir)) { diff --git a/cli/src/plugins/GenericSkillsOutputPlugin.test.ts b/cli/src/plugins/GenericSkillsOutputPlugin.test.ts index 9ab10f43..4d3f90dd 100644 --- a/cli/src/plugins/GenericSkillsOutputPlugin.test.ts +++ b/cli/src/plugins/GenericSkillsOutputPlugin.test.ts @@ -237,7 +237,9 @@ describe('genericSkillsOutputPlugin', () => { const results = await plugin.registerGlobalOutputDirs(ctx) expect(results).toHaveLength(1) - expect(results[0]?.path).toBe(path.join('.aindex', '.skills')) + const pathValue = results[0]?.path.replaceAll('\\', '/') + const expected = path.join('.aindex', '.skills').replaceAll('\\', '/') + expect(pathValue).toBe(expected) expect(results[0]?.basePath).toBe(mockHomeDir) }) diff --git a/cli/src/plugins/GitExcludeOutputPlugin.test.ts b/cli/src/plugins/GitExcludeOutputPlugin.test.ts index 2b53b3fc..d80e1ab9 100644 --- a/cli/src/plugins/GitExcludeOutputPlugin.test.ts +++ b/cli/src/plugins/GitExcludeOutputPlugin.test.ts @@ -55,7 +55,8 @@ describe('gitExcludeOutputPlugin', () => { expect(result.files.length).toBeGreaterThanOrEqual(1) expect(spy).toHaveBeenCalled() - const writtenContent = spy.mock.calls[0][1] as string + const firstCall = spy.mock.calls[0] + const writtenContent = (firstCall?.[1] ?? '') as string expect(writtenContent).toBe('dist/\n') }) @@ -107,7 +108,8 @@ describe('gitExcludeOutputPlugin', () => { const spy = vi.mocked(fs.writeFileSync) await plugin.writeProjectOutputs(ctx) - const writtenContent = spy.mock.calls[0][1] as string + const firstCall = spy.mock.calls[0] + const writtenContent = (firstCall?.[1] ?? '') as string expect(writtenContent).toContain('node_modules/') expect(writtenContent).toContain('.idea/') expect(writtenContent).toContain('*.log') @@ -143,7 +145,8 @@ describe('gitExcludeOutputPlugin', () => { const spy = vi.mocked(fs.writeFileSync) await plugin.writeProjectOutputs(ctx) - const writtenContent = spy.mock.calls[0][1] as string + const firstCall = spy.mock.calls[0] + const writtenContent = (firstCall?.[1] ?? '') as string expect(writtenContent).toBe('new-content/\n') }) @@ -177,7 +180,8 @@ describe('gitExcludeOutputPlugin', () => { const spy = vi.mocked(fs.writeFileSync) await plugin.writeProjectOutputs(ctx) - const writtenContent = spy.mock.calls[0][1] as string + const firstCall = spy.mock.calls[0] + const writtenContent = (firstCall?.[1] ?? '') as string expect(writtenContent).toContain('.cache/') }) @@ -201,35 +205,27 @@ describe('gitExcludeOutputPlugin', () => { } ] } - }, - logger: createLogger('test', 'debug'), - dryRun: false + } } as any vi.mocked(fs.existsSync).mockImplementation((p: any) => { - const s = String(p) + const s = String(p).replaceAll('\\', '/') return s === '/ws/submod/.git' || s === '/ws/.git' }) vi.mocked(fs.lstatSync).mockImplementation((p: any) => { - const s = String(p) + const s = String(p).replaceAll('\\', '/') if (s === '/ws/submod/.git') return fileStat // submodule: .git is a file return dirStat // workspace root: .git is a directory }) vi.mocked(fs.readFileSync).mockImplementation((p: any) => { - if (String(p) === '/ws/submod/.git') return 'gitdir: ../.git/modules/submod' + const s = String(p).replaceAll('\\', '/') + if (s === '/ws/submod/.git') return 'gitdir: ../.git/modules/submod' return '' }) vi.mocked(fs.readdirSync).mockReturnValue([] as any) - vi.mocked(fs.writeFileSync).mockImplementation(() => {}) - vi.mocked(fs.mkdirSync).mockImplementation(() => '') - - const spy = vi.mocked(fs.writeFileSync) - const result = await plugin.writeProjectOutputs(ctx) - - expect(result.files.length).toBeGreaterThanOrEqual(1) - expect(spy).toHaveBeenCalled() - const writtenPath = String(spy.mock.calls[0][0]) - expect(writtenPath).toContain('.git/modules/submod/info/exclude') // Should write to resolved gitdir path + const results = await plugin.registerProjectOutputFiles(ctx) + const absPaths = results.map(r => r.getAbsolutePath().replaceAll('\\', '/')) + expect(absPaths).toContainEqual(expect.stringContaining('.git/modules/submod/info/exclude')) }) it('should write to .git/modules/*/info/exclude directly', async () => { @@ -242,9 +238,7 @@ describe('gitExcludeOutputPlugin', () => { directory: {path: '/ws'}, projects: [] } - }, - logger: createLogger('test', 'debug'), - dryRun: false + } } as any const infoDirent = {name: 'info', isDirectory: () => true, isFile: () => false} as any @@ -252,26 +246,20 @@ describe('gitExcludeOutputPlugin', () => { const modBDirent = {name: 'modB', isDirectory: () => true, isFile: () => false} as any vi.mocked(fs.existsSync).mockImplementation((p: any) => { - const s = String(p) + const s = String(p).replaceAll('\\', '/') return s === '/ws/.git' || s === '/ws/.git/modules' }) vi.mocked(fs.lstatSync).mockReturnValue(dirStat) vi.mocked(fs.readdirSync).mockImplementation((p: any) => { - const s = String(p) + const s = String(p).replaceAll('\\', '/') if (s === '/ws/.git/modules') return [modADirent, modBDirent] as any if (s === '/ws/.git/modules/modA') return [infoDirent] as any if (s === '/ws/.git/modules/modB') return [infoDirent] as any return [] as any }) - vi.mocked(fs.writeFileSync).mockImplementation(() => {}) - vi.mocked(fs.mkdirSync).mockImplementation(() => '') - - const spy = vi.mocked(fs.writeFileSync) - const result = await plugin.writeProjectOutputs(ctx) - - const writtenPaths = spy.mock.calls.map(c => String(c[0])) - expect(writtenPaths).toContainEqual(expect.stringContaining('.git/modules/modA/info/exclude')) - expect(writtenPaths).toContainEqual(expect.stringContaining('.git/modules/modB/info/exclude')) - expect(result.files.length).toBeGreaterThanOrEqual(2) + const results = await plugin.registerProjectOutputFiles(ctx) + const absPaths = results.map(r => r.getAbsolutePath().replaceAll('\\', '/')) + expect(absPaths).toContain('/ws/.git/modules/modA/info/exclude') + expect(absPaths).toContain('/ws/.git/modules/modB/info/exclude') }) }) diff --git a/cli/src/plugins/GitExcludeOutputPlugin.ts b/cli/src/plugins/GitExcludeOutputPlugin.ts index f2f3134a..28863426 100644 --- a/cli/src/plugins/GitExcludeOutputPlugin.ts +++ b/cli/src/plugins/GitExcludeOutputPlugin.ts @@ -100,17 +100,17 @@ function findGitModuleInfoDirs(dotGitDir: string): string[] { if (hasInfo) results.push(path.join(dir, 'info')) const nestedModules = entries.find(e => e.name === 'modules' && e.isDirectory()) // Recurse into nested modules/ - if (nestedModules != null) { - let subEntries: fs.Dirent[] - try { - const raw = fs.readdirSync(path.join(dir, 'modules'), {withFileTypes: true}) - if (!Array.isArray(raw)) return - subEntries = raw - } - catch { return } - for (const sub of subEntries) { - if (sub.isDirectory()) walk(path.join(dir, 'modules', sub.name)) - } + if (nestedModules == null) return + + let subEntries: fs.Dirent[] + try { + const raw = fs.readdirSync(path.join(dir, 'modules'), {withFileTypes: true}) + if (!Array.isArray(raw)) return + subEntries = raw + } + catch { return } + for (const sub of subEntries) { + if (sub.isDirectory()) walk(path.join(dir, 'modules', sub.name)) } } diff --git a/cli/src/plugins/KiroCLIOutputPlugin.ts b/cli/src/plugins/KiroCLIOutputPlugin.ts index 732cc1b0..525f9b0d 100644 --- a/cli/src/plugins/KiroCLIOutputPlugin.ts +++ b/cli/src/plugins/KiroCLIOutputPlugin.ts @@ -26,7 +26,7 @@ const SKILL_FILE_NAME = 'SKILL.md' export class KiroCLIOutputPlugin extends AbstractOutputPlugin { constructor() { - super('KiroCLIOutputPlugin', {globalConfigDir: GLOBAL_CONFIG_DIR, outputFileName: GLOBAL_MEMORY_FILE}) + super('KiroCLIOutputPlugin', {globalConfigDir: GLOBAL_CONFIG_DIR, outputFileName: GLOBAL_MEMORY_FILE, indexignore: '.kiroignore'}) this.registerCleanEffect('registry-cleanup', async ctx => { const writer = this.getRegistryWriter(KiroPowersRegistryWriter) @@ -89,6 +89,8 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { )) } } + + results.push(...this.registerProjectIgnoreOutputFiles(projects)) return results } @@ -176,9 +178,11 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, globalMemory, fastCommands, skills} = ctx.collectedInputContext + const {workspace, globalMemory, fastCommands, skills, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasChildPrompts = workspace.projects.some(p => (p.childMemoryPrompts?.length ?? 0) > 0) - if (hasChildPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0) return true + const hasKiroIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.kiroignore') ?? false + + if (hasChildPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0 || hasKiroIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } @@ -191,6 +195,10 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { if (project.dirFromWorkspacePath == null || project.childMemoryPrompts == null) continue for (const child of project.childMemoryPrompts) fileResults.push(await this.writeSteeringFile(ctx, project, child)) } + + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) + return {files: fileResults, dirs: []} } diff --git a/cli/src/plugins/QoderIDEPluginOutputPlugin.ts b/cli/src/plugins/QoderIDEPluginOutputPlugin.ts index ab87fc81..0e234d53 100644 --- a/cli/src/plugins/QoderIDEPluginOutputPlugin.ts +++ b/cli/src/plugins/QoderIDEPluginOutputPlugin.ts @@ -28,7 +28,7 @@ const RULE_GLOB_KEY = 'glob' export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { constructor() { - super('QoderIDEPluginOutputPlugin', {globalConfigDir: QODER_CONFIG_DIR}) + super('QoderIDEPluginOutputPlugin', {globalConfigDir: QODER_CONFIG_DIR, indexignore: '.qoderignore'}) } async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { @@ -55,6 +55,7 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { for (const child of project.childMemoryPrompts) results.push(this.createProjectRuleFilePath(projectDir, this.buildChildRuleFileName(child))) } } + results.push(...this.registerProjectIgnoreOutputFiles(projects)) return results } @@ -137,11 +138,12 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, globalMemory, fastCommands, skills} = ctx.collectedInputContext + const {workspace, globalMemory, fastCommands, skills, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasProjectPrompts = workspace.projects.some( p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 ) - if (hasProjectPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0) return true + const hasQoderIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.qoderignore') ?? false + if (hasProjectPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0 || hasQoderIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } @@ -173,6 +175,8 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { } } } + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) return {files: fileResults, dirs: []} } diff --git a/cli/src/plugins/ReadmeMdInputPlugin.property.test.ts b/cli/src/plugins/ReadmeMdInputPlugin.property.test.ts index d2030446..1d28301c 100644 --- a/cli/src/plugins/ReadmeMdInputPlugin.property.test.ts +++ b/cli/src/plugins/ReadmeMdInputPlugin.property.test.ts @@ -114,7 +114,7 @@ describe('readmeMdInputPlugin property tests', () => { }) } ), - {numRuns: 100} + {numRuns: 50} ) }) diff --git a/cli/src/plugins/TraeIDEOutputPlugin.test.ts b/cli/src/plugins/TraeIDEOutputPlugin.test.ts index b28ae069..3ca50ba7 100644 --- a/cli/src/plugins/TraeIDEOutputPlugin.test.ts +++ b/cli/src/plugins/TraeIDEOutputPlugin.test.ts @@ -33,7 +33,7 @@ function createMockFastCommandPrompt( class TestableTraeIDEOutputPlugin extends TraeIDEOutputPlugin { private mockHomeDir: string | null = null - public capturedWriteFile: {path: string; content: string} | null = null + public capturedWriteFile: {path: string, content: string} | null = null public testBuildFastCommandSteeringFileName(cmd: FastCommandPrompt): string { return (this as any).buildFastCommandSteeringFileName(cmd) @@ -58,7 +58,7 @@ class TestableTraeIDEOutputPlugin extends TraeIDEOutputPlugin { } } -describe('TraeIDEOutputPlugin', () => { +describe('traeIDEOutputPlugin', () => { describe('buildFastCommandSteeringFileName', () => { const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) .filter(s => /^[a-z0-9]+$/i.test(s)) @@ -102,7 +102,7 @@ describe('TraeIDEOutputPlugin', () => { }) }) - describe('writeSteeringFile (Child Memory Prompts)', () => { + describe('writeSteeringFile (child memory prompts)', () => { it('should write to .trae/rules with correct frontmatter', async () => { const plugin = new TestableTraeIDEOutputPlugin() const project = { @@ -112,8 +112,8 @@ describe('TraeIDEOutputPlugin', () => { } } as any const child = { - dir: { path: 'src/components' }, - workingChildDirectoryPath: { path: 'src/components' }, + dir: {path: 'src/components'}, + workingChildDirectoryPath: {path: 'src/components'}, content: 'child content' } as any const ctx = { @@ -124,12 +124,10 @@ describe('TraeIDEOutputPlugin', () => { expect(plugin.capturedWriteFile).not.toBeNull() const {path, content} = plugin.capturedWriteFile! - - // Verify path contains .trae/rules - expect(path.replaceAll('\\', '/')).toContain('/.trae/rules/') - - // Verify frontmatter - expect(content).toContain('---') + + expect(path.replaceAll('\\', '/')).toContain('/.trae/rules/') // Verify path contains .trae/rules + + expect(content).toContain('---') // Verify frontmatter expect(content).toContain('alwaysApply: false') expect(content).toContain('globs: src/components/**') expect(content).toContain('child content') diff --git a/cli/src/plugins/TraeIDEOutputPlugin.ts b/cli/src/plugins/TraeIDEOutputPlugin.ts index c7500397..64ea3966 100644 --- a/cli/src/plugins/TraeIDEOutputPlugin.ts +++ b/cli/src/plugins/TraeIDEOutputPlugin.ts @@ -17,7 +17,7 @@ const RULES_SUBDIR = 'rules' export class TraeIDEOutputPlugin extends AbstractOutputPlugin { constructor() { - super('TraeIDEOutputPlugin', {globalConfigDir: GLOBAL_CONFIG_DIR, outputFileName: GLOBAL_MEMORY_FILE}) + super('TraeIDEOutputPlugin', {globalConfigDir: GLOBAL_CONFIG_DIR, outputFileName: GLOBAL_MEMORY_FILE, indexignore: '.traeignore'}) } private getGlobalSteeringDir(): string { @@ -49,6 +49,8 @@ export class TraeIDEOutputPlugin extends AbstractOutputPlugin { )) } } + + results.push(...this.registerProjectIgnoreOutputFiles(projects)) return results } @@ -73,9 +75,10 @@ export class TraeIDEOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, globalMemory, fastCommands} = ctx.collectedInputContext + const {workspace, globalMemory, fastCommands, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasChildPrompts = workspace.projects.some(p => (p.childMemoryPrompts?.length ?? 0) > 0) - if (hasChildPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0) return true + const hasTraeIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.traeignore') ?? false + if (hasChildPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || hasTraeIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } @@ -88,6 +91,10 @@ export class TraeIDEOutputPlugin extends AbstractOutputPlugin { if (project.dirFromWorkspacePath == null || project.childMemoryPrompts == null) continue for (const child of project.childMemoryPrompts) fileResults.push(await this.writeSteeringFile(ctx, project, child)) } + + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) + return {files: fileResults, dirs: []} } diff --git a/cli/src/plugins/WarpIDEOutputPlugin.test.ts b/cli/src/plugins/WarpIDEOutputPlugin.test.ts index ecbc4c95..a390199f 100644 --- a/cli/src/plugins/WarpIDEOutputPlugin.test.ts +++ b/cli/src/plugins/WarpIDEOutputPlugin.test.ts @@ -103,8 +103,8 @@ describe('warpIDEOutputPlugin', () => { const results = await plugin.registerProjectOutputFiles(ctx) - expect(results).toHaveLength(1) - expect(results[0].path).toBe(path.join('project1', 'WARP.md')) + const paths = results.map(r => r.path) + expect(paths).toContain(path.join('project1', 'WARP.md')) }) it('should register WARP.md for child memory prompts', async () => { @@ -139,8 +139,8 @@ describe('warpIDEOutputPlugin', () => { const results = await plugin.registerProjectOutputFiles(ctx) - expect(results).toHaveLength(1) - expect(results[0].path).toBe(path.join('project1', 'src', 'WARP.md')) + const paths = results.map(r => r.path) + expect(paths).toContain(path.join('project1', 'src', 'WARP.md')) }) it('should return empty array when no prompts exist', async () => { diff --git a/cli/src/plugins/WarpIDEOutputPlugin.ts b/cli/src/plugins/WarpIDEOutputPlugin.ts index 0c356ccf..398c4cec 100644 --- a/cli/src/plugins/WarpIDEOutputPlugin.ts +++ b/cli/src/plugins/WarpIDEOutputPlugin.ts @@ -11,7 +11,7 @@ const PROJECT_MEMORY_FILE = 'WARP.md' export class WarpIDEOutputPlugin extends AbstractOutputPlugin { constructor() { - super('WarpIDEOutputPlugin', {outputFileName: PROJECT_MEMORY_FILE}) + super('WarpIDEOutputPlugin', {outputFileName: PROJECT_MEMORY_FILE, indexignore: '.warpindexignore'}) } private isAgentsPluginRegistered(ctx: OutputPluginContext | OutputWriteContext): boolean { @@ -40,12 +40,13 @@ export class WarpIDEOutputPlugin extends AbstractOutputPlugin { } } + results.push(...this.registerProjectIgnoreOutputFiles(projects)) return results } async canWrite(ctx: OutputWriteContext): Promise { const agentsRegistered = this.isAgentsPluginRegistered(ctx) - const {workspace, globalMemory} = ctx.collectedInputContext + const {workspace, globalMemory, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext if (agentsRegistered) { if (globalMemory == null) { // When AgentsOutputPlugin is registered, only write if we have global memory @@ -59,7 +60,9 @@ export class WarpIDEOutputPlugin extends AbstractOutputPlugin { p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 ) - if (hasProjectOutputs) return true + const hasWarpIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.warpindexignore') ?? false + + if (hasProjectOutputs || hasWarpIgnore) return true this.log.debug('skipped', {reason: 'no outputs to write'}) return false @@ -73,21 +76,24 @@ export class WarpIDEOutputPlugin extends AbstractOutputPlugin { const dirResults: WriteResult[] = [] if (agentsRegistered) { - if (globalMemory == null) return {files: [], dirs: []} // When AgentsOutputPlugin is registered, write global prompt to each project's WARP.md - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - - const projectName = project.name ?? 'unknown' - const result = await this.writePromptFile(ctx, projectDir, globalMemory.content as string, `project:${projectName}/global-warp`) - fileResults.push(result) + if (globalMemory != null) { + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + + const projectName = project.name ?? 'unknown' + const result = await this.writePromptFile(ctx, projectDir, globalMemory.content as string, `project:${projectName}/global-warp`) + fileResults.push(result) + } } + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) + return {files: fileResults, dirs: dirResults} } - const globalMemoryContent = this.extractGlobalMemoryContent(ctx) // AgentsOutputPlugin which outputs AGENTS.md files that Warp reads directly. // When users need child-level prompts with global context, they should use // as Warp supports AGENTS.md natively for hierarchical prompt inheritance. // Note: Child memory prompts are written without global memory prefix, // Normal mode: write combined content + const globalMemoryContent = this.extractGlobalMemoryContent(ctx) // Normal mode: write combined content for (const project of projects) { const projectName = project.name ?? 'unknown' @@ -96,7 +102,7 @@ export class WarpIDEOutputPlugin extends AbstractOutputPlugin { if (projectDir == null) continue if (project.rootMemoryPrompt != null) { // Write root memory prompt (only if exists) - const combinedContent = this.combineGlobalWithContent( // Combine global memory with root memory prompt using helper method + const combinedContent = this.combineGlobalWithContent( globalMemoryContent, project.rootMemoryPrompt.content as string ) @@ -113,6 +119,9 @@ export class WarpIDEOutputPlugin extends AbstractOutputPlugin { } } + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) + return {files: fileResults, dirs: dirResults} } } diff --git a/cli/src/plugins/WindsurfOutputPlugin.ts b/cli/src/plugins/WindsurfOutputPlugin.ts index 08d86c18..59d3f399 100644 --- a/cli/src/plugins/WindsurfOutputPlugin.ts +++ b/cli/src/plugins/WindsurfOutputPlugin.ts @@ -31,7 +31,8 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { super('WindsurfOutputPlugin', { globalConfigDir: CODEIUM_WINDSURF_DIR, outputFileName: '', - dependsOn: ['AgentsOutputPlugin'] + dependsOn: ['AgentsOutputPlugin'], + indexignore: '.codeignore' }) } @@ -131,12 +132,13 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {skills, fastCommands, globalMemory} = ctx.collectedInputContext + const {skills, fastCommands, globalMemory, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasSkills = (skills?.length ?? 0) > 0 const hasFastCommands = (fastCommands?.length ?? 0) > 0 const hasGlobalMemory = globalMemory != null + const hasCodeIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.codeignore') ?? false - if (hasSkills || hasFastCommands || hasGlobalMemory) return true + if (hasSkills || hasFastCommands || hasGlobalMemory || hasCodeIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false @@ -170,8 +172,9 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { return {files: fileResults, dirs: dirResults} } - async writeProjectOutputs(): Promise { - return {files: [], dirs: []} + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const fileResults = await this.writeProjectIgnoreFiles(ctx) + return {files: fileResults, dirs: []} } private getSkillsDir(): string { diff --git a/cli/src/plugins/jetbrains/JetBrainsAIAssistantCodexOutputPlugin.ts b/cli/src/plugins/jetbrains/JetBrainsAIAssistantCodexOutputPlugin.ts index 88124f1d..4802a96a 100644 --- a/cli/src/plugins/jetbrains/JetBrainsAIAssistantCodexOutputPlugin.ts +++ b/cli/src/plugins/jetbrains/JetBrainsAIAssistantCodexOutputPlugin.ts @@ -107,7 +107,8 @@ export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin constructor() { super('JetBrainsAIAssistantCodexOutputPlugin', { outputFileName: PROJECT_MEMORY_FILE, - dependsOn: ['AgentsOutputPlugin'] + dependsOn: ['AgentsOutputPlugin'], + indexignore: '.aiignore' }) } @@ -141,6 +142,7 @@ export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin } } + results.push(...this.registerProjectIgnoreOutputFiles(projects)) return results } @@ -189,15 +191,16 @@ export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin } async canWrite(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands, skills, workspace} = ctx.collectedInputContext + const {globalMemory, fastCommands, skills, workspace, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasGlobalMemory = globalMemory != null const hasFastCommands = (fastCommands?.length ?? 0) > 0 const hasSkills = (skills?.length ?? 0) > 0 const hasProjectPrompts = workspace.projects.some( project => project.rootMemoryPrompt != null || (project.childMemoryPrompts?.length ?? 0) > 0 ) + const hasAiIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.aiignore') ?? false - if (hasGlobalMemory || hasFastCommands || hasSkills || hasProjectPrompts) return true + if (hasGlobalMemory || hasFastCommands || hasSkills || hasProjectPrompts || hasAiIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false @@ -228,6 +231,9 @@ export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin } } + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) + return {files: fileResults, dirs: dirResults} } diff --git a/gui/package.json b/gui/package.json index 148aaa78..b346908e 100644 --- a/gui/package.json +++ b/gui/package.json @@ -2,6 +2,11 @@ "name": "@truenine/memory-sync-gui", "version": "2026.10214.1083059", "private": true, + "engines": { + "node": ">=25.2.1", + "pnpm": ">=10.28.0", + "rust": ">=1.93.1" + }, "type": "module", "scripts": { "dev": "vite", @@ -14,7 +19,7 @@ "typecheck": "tsc --noEmit", "test:ui": "vitest --run", "test:tauri": "cargo test --manifest-path src-tauri/Cargo.toml", - "test": "pnpm run test:ui && pnpm run test:tauri" + "test": "pnpm run test:ui && pnpm tsx ./scripts/run-tauri-tests.ts" }, "dependencies": { "@monaco-editor/react": "catalog:", diff --git a/gui/scripts/run-tauri-tests.ts b/gui/scripts/run-tauri-tests.ts new file mode 100644 index 00000000..3647ab2b --- /dev/null +++ b/gui/scripts/run-tauri-tests.ts @@ -0,0 +1,32 @@ +import {spawnSync} from 'node:child_process' + +function cargoAvailable(): boolean { + const result = spawnSync('cargo', ['--version'], { + stdio: 'ignore', + shell: process.platform === 'win32' + }) + return result.status === 0 +} + +if (!cargoAvailable()) { + // Skip Tauri tests when Rust toolchain is not installed locally so that + // JS/Vitest tests can still pass. CI or dev machines with cargo installed + // will still run the full `test:tauri` suite. + // eslint-disable-next-line no-console + console.warn('[memory-sync-gui] cargo not found on PATH, skipping Tauri tests (test:tauri).') + process.exit(0) +} + +const child = spawnSync('pnpm', ['run', 'test:tauri'], { + stdio: 'inherit', + shell: process.platform === 'win32' +}) + +if (child.error != null) { + // eslint-disable-next-line no-console + console.error('[memory-sync-gui] Failed to run pnpm test:tauri:', child.error) + process.exit(1) +} + +process.exit(child.status ?? 1) + From 3dee5ce0f96778913c2f88c884cb5adf728c8592 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 17 Feb 2026 20:27:11 +0800 Subject: [PATCH 4/5] chore: auto-generated commit (0 added, 2 modified, 0 deleted) Co-authored-by: Cursor --- .../plugins/OpencodeCLIOutputPlugin.test.ts | 131 ++++++++++++++++++ cli/src/plugins/OpencodeCLIOutputPlugin.ts | 14 ++ 2 files changed, 145 insertions(+) diff --git a/cli/src/plugins/OpencodeCLIOutputPlugin.test.ts b/cli/src/plugins/OpencodeCLIOutputPlugin.test.ts index fbbd53f3..61d24d8f 100644 --- a/cli/src/plugins/OpencodeCLIOutputPlugin.test.ts +++ b/cli/src/plugins/OpencodeCLIOutputPlugin.test.ts @@ -429,6 +429,137 @@ describe('opencodeCLIOutputPlugin', () => { expect(content.mcp['remote-server'].url).toBe('https://example.com/mcp') expect(content.mcp['remote-server'].enabled).toBe(true) }) + + it('should add opencode-rules@latest to plugin array when writing mcp config', async () => { + const mockSkill: SkillPrompt = { + type: PromptKind.Skill, + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('test-skill', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'test-skill', description: 'desc'}, + mcpConfig: { + type: PromptKind.SkillMcpConfig, + rawContent: '{}', + mcpServers: { + 'local-server': { + command: 'node' + } + } + } + } + + const ctxWithSkill = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + skills: [mockSkill] + } + } + + await plugin.writeGlobalOutputs(ctxWithSkill) + + const configPath = path.join(tempDir, '.config/opencode/opencode.json') + const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(Array.isArray(content.plugin)).toBe(true) + expect((content.plugin as unknown[])).toContain('opencode-rules@latest') + }) + + it('should preserve existing plugins and append opencode-rules@latest only once', async () => { + const opencodeDir = path.join(tempDir, '.config/opencode') + fs.mkdirSync(opencodeDir, {recursive: true}) + const configPath = path.join(opencodeDir, 'opencode.json') + fs.writeFileSync( + configPath, + JSON.stringify({plugin: ['existing-plugin', 'opencode-rules@latest']}, null, 2), + 'utf8' + ) + + const mockSkill: SkillPrompt = { + type: PromptKind.Skill, + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('test-skill', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'test-skill', description: 'desc'}, + mcpConfig: { + type: PromptKind.SkillMcpConfig, + rawContent: '{}', + mcpServers: { + 'local-server': { + command: 'node' + } + } + } + } + + const ctxWithSkill = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + skills: [mockSkill] + } + } + + await plugin.writeGlobalOutputs(ctxWithSkill) + + const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(content.plugin).toEqual(['existing-plugin', 'opencode-rules@latest']) + }) + }) + + describe('clean effect', () => { + it('should remove opencode-rules@latest from plugin array on clean', async () => { + const opencodeDir = path.join(tempDir, '.config/opencode') + fs.mkdirSync(opencodeDir, {recursive: true}) + const configPath = path.join(opencodeDir, 'opencode.json') + fs.writeFileSync( + configPath, + JSON.stringify({mcp: {some: {command: 'npx'}}, plugin: ['a', 'opencode-rules@latest', 'b']}, null, 2), + 'utf8' + ) + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)} + }, + logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()}, + dryRun: false + } as any + + await plugin.onCleanComplete(ctx) + + const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(content.mcp).toEqual({}) + expect(content.plugin).toEqual(['a', 'b']) + }) + + it('should delete plugin field when opencode-rules@latest is the only plugin on clean', async () => { + const opencodeDir = path.join(tempDir, '.config/opencode') + fs.mkdirSync(opencodeDir, {recursive: true}) + const configPath = path.join(opencodeDir, 'opencode.json') + fs.writeFileSync( + configPath, + JSON.stringify({mcp: {some: {command: 'npx'}}, plugin: ['opencode-rules@latest']}, null, 2), + 'utf8' + ) + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)} + }, + logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()}, + dryRun: false + } as any + + await plugin.onCleanComplete(ctx) + + const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(content.mcp).toEqual({}) + expect(content.plugin).toBeUndefined() + }) }) describe('writeGlobalOutputs sub-agent mdx regression', () => { diff --git a/cli/src/plugins/OpencodeCLIOutputPlugin.ts b/cli/src/plugins/OpencodeCLIOutputPlugin.ts index f6f11046..a22f74a1 100644 --- a/cli/src/plugins/OpencodeCLIOutputPlugin.ts +++ b/cli/src/plugins/OpencodeCLIOutputPlugin.ts @@ -8,6 +8,7 @@ import {BaseCLIOutputPlugin} from './BaseCLIOutputPlugin' const GLOBAL_MEMORY_FILE = 'AGENTS.md' const GLOBAL_CONFIG_DIR = '.config/opencode' const OPENCODE_CONFIG_FILE = 'opencode.json' +const OPENCODE_RULES_PLUGIN_NAME = 'opencode-rules@latest' /** * Opencode CLI output plugin. @@ -50,6 +51,14 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { const existingContent = fs.readFileSync(configPath, 'utf8') const existingConfig = JSON.parse(existingContent) as Record existingConfig['mcp'] = {} + + const pluginField = existingConfig['plugin'] + if (Array.isArray(pluginField)) { + const filtered = pluginField.filter(item => item !== OPENCODE_RULES_PLUGIN_NAME) + if (filtered.length > 0) existingConfig['plugin'] = filtered + else delete existingConfig['plugin'] + } + fs.writeFileSync(configPath, JSON.stringify(existingConfig, null, 2)) } this.log.trace({action: 'clean', type: 'mcpConfigCleanup', path: configPath}) @@ -159,6 +168,11 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { existingConfig['$schema'] = 'https://opencode.ai/config.json' existingConfig['mcp'] = mergedMcpServers + const pluginField = existingConfig['plugin'] + const plugins: string[] = Array.isArray(pluginField) ? pluginField.map(item => String(item)) : [] + if (!plugins.includes(OPENCODE_RULES_PLUGIN_NAME)) plugins.push(OPENCODE_RULES_PLUGIN_NAME) + existingConfig['plugin'] = plugins + const content = JSON.stringify(existingConfig, null, 2) if (ctx.dryRun === true) { From fdceafdace48e55e0038b40ea4a30304163f7602 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 17 Feb 2026 22:22:53 +0800 Subject: [PATCH 5/5] 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. --- cli/package.json | 2 +- cli/src/PluginPipeline.ts | 6 + cli/src/config.ts | 2 + cli/src/plugin.config.ts | 2 + cli/src/plugins/CursorOutputPlugin.ts | 159 ++++++++++-- cli/src/plugins/KiroCLIOutputPlugin.ts | 94 ++++++- cli/src/plugins/RuleInputPlugin.test.ts | 234 ++++++++++++++++++ cli/src/plugins/RuleInputPlugin.ts | 176 +++++++++++++ cli/src/plugins/WindsurfOutputPlugin.ts | 182 +++++++++++++- cli/src/types/ConfigTypes.ts | 2 + cli/src/types/Enums.ts | 8 +- cli/src/types/ExportMetadataTypes.ts | 36 ++- cli/src/types/InputTypes.ts | 17 +- cli/src/types/PluginTypes.ts | 2 + cli/src/types/PromptTypes.ts | 10 +- gui/package.json | 2 +- gui/src-tauri/Cargo.toml | 2 +- gui/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- .../public/public/tnmsc.example.json | 1 + 20 files changed, 891 insertions(+), 50 deletions(-) create mode 100644 cli/src/plugins/RuleInputPlugin.test.ts create mode 100644 cli/src/plugins/RuleInputPlugin.ts diff --git a/cli/package.json b/cli/package.json index 991fee79..22209003 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10214.1083059", + "version": "2026.10217.12221", "description": "TrueNine Memory Synchronization CLI", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/cli/src/PluginPipeline.ts b/cli/src/PluginPipeline.ts index 8d4a3ce3..ee33a3ea 100644 --- a/cli/src/PluginPipeline.ts +++ b/cli/src/PluginPipeline.ts @@ -647,6 +647,11 @@ export class PluginPipeline { ? [...base.skills ?? [], ...addition.skills] : base.skills + const rules: CollectedInputContext['rules'] | undefined + = addition.rules != null + ? [...base.rules ?? [], ...addition.rules] + : base.rules + const aiAgentIgnoreConfigFiles: CollectedInputContext['aiAgentIgnoreConfigFiles'] | undefined = addition.aiAgentIgnoreConfigFiles != null ? [...base.aiAgentIgnoreConfigFiles ?? [], ...addition.aiAgentIgnoreConfigFiles] @@ -675,6 +680,7 @@ export class PluginPipeline { ...fastCommands != null ? {fastCommands} : {}, ...subAgents != null ? {subAgents} : {}, ...skills != null ? {skills} : {}, + ...rules != null ? {rules} : {}, ...aiAgentIgnoreConfigFiles != null ? {aiAgentIgnoreConfigFiles} : {}, ...globalMemory != null ? {globalMemory} : {}, ...shadowSourceProjectDir != null ? {shadowSourceProjectDir} : {}, diff --git a/cli/src/config.ts b/cli/src/config.ts index a922067b..e1b926d9 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -39,6 +39,7 @@ function userConfigToPluginOptions(userConfig: UserConfigFile): Partial([ 'create-rule', @@ -75,7 +77,7 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { const results: RelativePath[] = [] const globalDir = this.getGlobalConfigDir() - const {fastCommands, skills} = ctx.collectedInputContext + const {fastCommands, skills, rules} = ctx.collectedInputContext if (fastCommands != null && fastCommands.length > 0) { const commandsDir = this.getGlobalCommandsDir() @@ -104,6 +106,17 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { } } + const globalRules = rules?.filter(r => r.scope === 'global') + if (globalRules == null || globalRules.length === 0) return results + + const globalRulesDir = path.join(globalDir, RULES_SUBDIR) + results.push({ + pathKind: FilePathKind.Relative, + path: RULES_SUBDIR, + basePath: globalDir, + getDirectoryName: () => RULES_SUBDIR, + getAbsolutePath: () => globalRulesDir + }) return results } @@ -140,6 +153,22 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { } } + const globalRules = ctx.collectedInputContext.rules?.filter(r => r.scope === 'global') + if (globalRules != null && globalRules.length > 0) { + const globalRulesDir = path.join(globalDir, RULES_SUBDIR) + for (const rule of globalRules) { + const fileName = this.buildRuleFileName(rule) + const fullPath = path.join(globalRulesDir, fileName) + results.push({ + pathKind: FilePathKind.Relative, + path: path.join(RULES_SUBDIR, fileName), + basePath: globalDir, + getDirectoryName: () => RULES_SUBDIR, + getAbsolutePath: () => fullPath + }) + } + } + if (skills == null || skills.length === 0) return results const skillsCursorDir = this.getSkillsCursorDir() @@ -196,8 +225,10 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { const results: RelativePath[] = [] - const {workspace, globalMemory} = ctx.collectedInputContext - if (globalMemory == null) return results + const {workspace, globalMemory, rules} = ctx.collectedInputContext + const hasProjectRules = rules?.some(r => r.scope === 'project') ?? false + + if (globalMemory == null && !hasProjectRules) return results for (const project of workspace.projects) { const projectDir = project.dirFromWorkspacePath @@ -209,35 +240,53 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { const results: RelativePath[] = [] - const {workspace, globalMemory} = ctx.collectedInputContext - if (globalMemory == null) return results + const {workspace, globalMemory, rules} = ctx.collectedInputContext + const projectRules = rules?.filter(r => r.scope === 'project') + const hasProjectRules = projectRules != null && projectRules.length > 0 - for (const project of workspace.projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - results.push(this.createProjectRuleFileRelativePath(projectDir, GLOBAL_RULE_FILE)) + if (globalMemory == null && !hasProjectRules) return results + + if (globalMemory != null) { + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + results.push(this.createProjectRuleFileRelativePath(projectDir, GLOBAL_RULE_FILE)) + } + } + + if (hasProjectRules) { + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + for (const rule of projectRules) { + const fileName = this.buildRuleFileName(rule) + results.push(this.createProjectRuleFileRelativePath(projectDir, fileName)) + } + } } + results.push(...this.registerProjectIgnoreOutputFiles(workspace.projects)) return results } async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, skills, fastCommands, globalMemory, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + const {workspace, skills, fastCommands, globalMemory, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasSkills = (skills?.length ?? 0) > 0 const hasFastCommands = (fastCommands?.length ?? 0) > 0 + const hasRules = (rules?.length ?? 0) > 0 const hasGlobalRuleOutput = globalMemory != null && workspace.projects.some(p => p.dirFromWorkspacePath != null) const hasCursorIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.cursorignore') ?? false - if (hasSkills || hasFastCommands || hasGlobalRuleOutput || hasCursorIgnore) return true + if (hasSkills || hasFastCommands || hasGlobalRuleOutput || hasRules || hasCursorIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {skills, fastCommands} = ctx.collectedInputContext + const {skills, fastCommands, rules} = ctx.collectedInputContext const fileResults: WriteResult[] = [] const dirResults: WriteResult[] = [] @@ -255,20 +304,30 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { } } - if (fastCommands == null || fastCommands.length === 0) return {files: fileResults, dirs: dirResults} + if (fastCommands != null && fastCommands.length > 0) { + const commandsDir = this.getGlobalCommandsDir() + for (const cmd of fastCommands) { + const result = await this.writeGlobalFastCommand(ctx, commandsDir, cmd) + fileResults.push(result) + } + } - const commandsDir = this.getGlobalCommandsDir() - for (const cmd of fastCommands) { - const result = await this.writeGlobalFastCommand(ctx, commandsDir, cmd) - fileResults.push(result) + const globalRules = rules?.filter(r => r.scope === 'global') + if (globalRules != null && globalRules.length > 0) { + const globalRulesDir = path.join(this.getGlobalConfigDir(), RULES_SUBDIR) + for (const rule of globalRules) { + const result = await this.writeRuleMdcFile(ctx, globalRulesDir, rule, this.getGlobalConfigDir()) + fileResults.push(result) + } } + return {files: fileResults, dirs: dirResults} } async writeProjectOutputs(ctx: OutputWriteContext): Promise { const fileResults: WriteResult[] = [] const dirResults: WriteResult[] = [] - const {workspace, globalMemory} = ctx.collectedInputContext + const {workspace, globalMemory, rules} = ctx.collectedInputContext if (globalMemory != null) { const content = this.buildGlobalRuleContent(globalMemory.content as string) for (const project of workspace.projects) { @@ -279,6 +338,19 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { } } + const projectRules = rules?.filter(r => r.scope === 'project') + if (projectRules != null && projectRules.length > 0) { + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + const rulesDir = path.join(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR) + for (const rule of projectRules) { + const result = await this.writeRuleMdcFile(ctx, rulesDir, rule, projectDir.basePath) + fileResults.push(result) + } + } + } + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) fileResults.push(...ignoreResults) @@ -662,4 +734,55 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { return {path: relativePath, success: false, error: error as Error} } } + + private buildRuleFileName(rule: RulePrompt): string { + return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.mdc` + } + + private buildRuleMdcContent(rule: RulePrompt): string { + const description = rule.yamlFrontMatter?.description ?? '' + const fmData: Record = { + description, + globs: [...rule.globs], + alwaysApply: false + } + return buildMarkdownWithFrontMatter(fmData, rule.content) + } + + private async writeRuleMdcFile( + ctx: OutputWriteContext, + rulesDir: string, + rule: RulePrompt, + basePath: string + ): Promise { + const fileName = this.buildRuleFileName(rule) + const fullPath = path.join(rulesDir, fileName) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.join(GLOBAL_CONFIG_DIR, RULES_SUBDIR, fileName), + basePath, + getDirectoryName: () => RULES_SUBDIR, + getAbsolutePath: () => fullPath + } + + const content = this.buildRuleMdcContent(rule) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'ruleFile', path: fullPath}) + return {path: relativePath, success: true, skipped: false} + } + + try { + this.ensureDirectory(rulesDir) + this.writeFileSync(fullPath, content) + this.log.trace({action: 'write', type: 'ruleFile', path: fullPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'ruleFile', path: fullPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } } diff --git a/cli/src/plugins/KiroCLIOutputPlugin.ts b/cli/src/plugins/KiroCLIOutputPlugin.ts index 525f9b0d..f26839de 100644 --- a/cli/src/plugins/KiroCLIOutputPlugin.ts +++ b/cli/src/plugins/KiroCLIOutputPlugin.ts @@ -5,6 +5,7 @@ import type { Project, ProjectChildrenMemoryPrompt, RegistryOperationResult, + RulePrompt, SkillPrompt, SkillYAMLFrontMatter, WriteResult, @@ -23,6 +24,7 @@ const KIRO_POWERS_DIR = '.kiro/powers/installed' const KIRO_SKILLS_DIR = '.kiro/skills' const POWER_FILE_NAME = 'POWER.md' const SKILL_FILE_NAME = 'SKILL.md' +const RULE_FILE_PREFIX = 'rule-' export class KiroCLIOutputPlugin extends AbstractOutputPlugin { constructor() { @@ -77,16 +79,32 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { const {projects} = ctx.collectedInputContext.workspace + const {rules} = ctx.collectedInputContext const results: RelativePath[] = [] for (const project of projects) { - if (project.dirFromWorkspacePath == null || project.childMemoryPrompts == null) continue - for (const child of project.childMemoryPrompts) { - results.push(this.createRelativePath( - this.joinPath(project.dirFromWorkspacePath.path, GLOBAL_CONFIG_DIR, STEERING_SUBDIR, this.buildSteeringFileName(child)), - project.dirFromWorkspacePath.basePath, - () => STEERING_SUBDIR - )) + if (project.dirFromWorkspacePath == null) continue + + if (project.childMemoryPrompts != null) { + for (const child of project.childMemoryPrompts) { + results.push(this.createRelativePath( + this.joinPath(project.dirFromWorkspacePath.path, GLOBAL_CONFIG_DIR, STEERING_SUBDIR, this.buildSteeringFileName(child)), + project.dirFromWorkspacePath.basePath, + () => STEERING_SUBDIR + )) + } + } + + const projectRules = rules?.filter(r => r.scope === 'project') + if (projectRules != null && projectRules.length > 0) { + for (const rule of projectRules) { + const fileName = this.buildRuleSteeringFileName(rule) + results.push(this.createRelativePath( + this.joinPath(project.dirFromWorkspacePath.path, GLOBAL_CONFIG_DIR, STEERING_SUBDIR, fileName), + project.dirFromWorkspacePath.basePath, + () => STEERING_SUBDIR + )) + } } } @@ -119,7 +137,7 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { } async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const {globalMemory, fastCommands, skills} = ctx.collectedInputContext + const {globalMemory, fastCommands, skills, rules} = ctx.collectedInputContext const steeringDir = this.getGlobalSteeringDir() const results: RelativePath[] = [] @@ -129,6 +147,11 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { for (const cmd of fastCommands) results.push(this.createRelativePath(this.buildFastCommandSteeringFileName(cmd), steeringDir, () => STEERING_SUBDIR)) } + const globalRules = rules?.filter(r => r.scope === 'global') + if (globalRules != null && globalRules.length > 0) { + for (const rule of globalRules) results.push(this.createRelativePath(this.buildRuleSteeringFileName(rule), steeringDir, () => STEERING_SUBDIR)) + } + if (skills == null) return results const powersDir = this.getKiroPowersDir() @@ -178,22 +201,32 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, globalMemory, fastCommands, skills, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + const {workspace, globalMemory, fastCommands, skills, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasChildPrompts = workspace.projects.some(p => (p.childMemoryPrompts?.length ?? 0) > 0) + const hasRules = (rules?.length ?? 0) > 0 const hasKiroIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.kiroignore') ?? false - if (hasChildPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0 || hasKiroIgnore) return true + if (hasChildPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0 || hasRules || hasKiroIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } async writeProjectOutputs(ctx: OutputWriteContext): Promise { const {projects} = ctx.collectedInputContext.workspace + const {rules} = ctx.collectedInputContext const fileResults: WriteResult[] = [] for (const project of projects) { - if (project.dirFromWorkspacePath == null || project.childMemoryPrompts == null) continue - for (const child of project.childMemoryPrompts) fileResults.push(await this.writeSteeringFile(ctx, project, child)) + if (project.dirFromWorkspacePath == null) continue + + if (project.childMemoryPrompts != null) { + for (const child of project.childMemoryPrompts) fileResults.push(await this.writeSteeringFile(ctx, project, child)) + } + + const projectRules = rules?.filter(r => r.scope === 'project') + if (projectRules != null && projectRules.length > 0) { + for (const rule of projectRules) fileResults.push(await this.writeRuleSteeringFile(ctx, project, rule)) + } } const ignoreResults = await this.writeProjectIgnoreFiles(ctx) @@ -203,7 +236,7 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { } async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands, skills} = ctx.collectedInputContext + const {globalMemory, fastCommands, skills, rules} = ctx.collectedInputContext const fileResults: WriteResult[] = [] const registryResults: RegistryOperationResult[] = [] const steeringDir = this.getGlobalSteeringDir() @@ -216,6 +249,16 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { for (const cmd of fastCommands) fileResults.push(await this.writeFastCommandSteeringFile(ctx, cmd)) } + const globalRules = rules?.filter(r => r.scope === 'global') + if (globalRules != null && globalRules.length > 0) { + for (const rule of globalRules) { + const fileName = this.buildRuleSteeringFileName(rule) + const fullPath = this.joinPath(steeringDir, fileName) + const content = this.buildRuleSteeringContent(rule) + fileResults.push(await this.writeFile(ctx, fullPath, content, 'ruleSteeringFile')) + } + } + if (skills == null || skills.length === 0) return {files: fileResults, dirs: []} const powerSkills = skills.filter(s => s.mcpConfig != null) @@ -355,6 +398,31 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { return `kiro-${normalized}.md` } + private buildRuleSteeringFileName(rule: RulePrompt): string { + return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` + } + + private buildRuleSteeringContent(rule: RulePrompt): string { + const fileMatchPattern = rule.globs.length === 1 + ? rule.globs[0] + : `{${rule.globs.join(',')}}` + + return this.buildMarkdownContent(rule.content, { + inclusion: 'fileMatch', + fileMatchPattern + }) + } + + private async writeRuleSteeringFile(ctx: OutputWriteContext, project: Project, rule: RulePrompt): Promise { + const projectDir = project.dirFromWorkspacePath! + const fileName = this.buildRuleSteeringFileName(rule) + const targetDir = this.joinPath(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, STEERING_SUBDIR) + const fullPath = this.joinPath(targetDir, fileName) + const content = this.buildRuleSteeringContent(rule) + + return this.writeFile(ctx, fullPath, content, 'ruleSteeringFile') + } + private async writeSteeringFile(ctx: OutputWriteContext, project: Project, child: ProjectChildrenMemoryPrompt): Promise { const projectDir = project.dirFromWorkspacePath! const fileName = this.buildSteeringFileName(child) diff --git a/cli/src/plugins/RuleInputPlugin.test.ts b/cli/src/plugins/RuleInputPlugin.test.ts new file mode 100644 index 00000000..26c5e811 --- /dev/null +++ b/cli/src/plugins/RuleInputPlugin.test.ts @@ -0,0 +1,234 @@ +import type {RulePrompt} from '@/types' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {validateRuleMetadata} from '@/types' + +describe('validateRuleMetadata', () => { + it('should pass with valid metadata', () => { + const result = validateRuleMetadata({ + globs: ['src/**/*.ts'], + description: 'TypeScript rules' + }) + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + + it('should pass with valid metadata including scope', () => { + const result = validateRuleMetadata({ + globs: ['src/**/*.ts', '**/*.tsx'], + description: 'TypeScript rules', + scope: 'global' + }) + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + expect(result.warnings).toHaveLength(0) + }) + + it('should warn when scope is not provided', () => { + const result = validateRuleMetadata({ + globs: ['src/**'], + description: 'Some rules' + }) + expect(result.valid).toBe(true) + expect(result.warnings.length).toBeGreaterThan(0) + expect(result.warnings[0]).toContain('scope') + }) + + it('should fail when globs is missing', () => { + const result = validateRuleMetadata({description: 'No globs'}) + expect(result.valid).toBe(false) + expect(result.errors.some(e => e.includes('globs'))).toBe(true) + }) + + it('should fail when globs is empty array', () => { + const result = validateRuleMetadata({ + globs: [], + description: 'Empty globs' + }) + expect(result.valid).toBe(false) + expect(result.errors.some(e => e.includes('globs'))).toBe(true) + }) + + it('should fail when globs contains non-string values', () => { + const result = validateRuleMetadata({ + globs: [123, true], + description: 'Bad globs' + }) + expect(result.valid).toBe(false) + expect(result.errors.some(e => e.includes('globs'))).toBe(true) + }) + + it('should fail when description is missing', () => { + const result = validateRuleMetadata({ + globs: ['**/*.ts'] + }) + expect(result.valid).toBe(false) + expect(result.errors.some(e => e.includes('description'))).toBe(true) + }) + + it('should fail when description is empty string', () => { + const result = validateRuleMetadata({ + globs: ['**/*.ts'], + description: '' + }) + expect(result.valid).toBe(false) + expect(result.errors.some(e => e.includes('description'))).toBe(true) + }) + + it('should fail when scope is invalid', () => { + const result = validateRuleMetadata({ + globs: ['**/*.ts'], + description: 'Valid desc', + scope: 'invalid' + }) + expect(result.valid).toBe(false) + expect(result.errors.some(e => e.includes('scope'))).toBe(true) + }) + + it('should accept scope "project"', () => { + const result = validateRuleMetadata({ + globs: ['**/*.ts'], + description: 'Valid', + scope: 'project' + }) + expect(result.valid).toBe(true) + }) + + it('should accept scope "global"', () => { + const result = validateRuleMetadata({ + globs: ['**/*.ts'], + description: 'Valid', + scope: 'global' + }) + expect(result.valid).toBe(true) + }) + + it('should include filePath in error messages when provided', () => { + const result = validateRuleMetadata({}, 'test/file.mdx') + expect(result.valid).toBe(false) + expect(result.errors.every(e => e.includes('test/file.mdx'))).toBe(true) + }) +}) + +describe('ruleInputPlugin - file structure', () => { + let tempDir: string + + beforeEach(() => tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rule-input-test-'))) + + afterEach(() => fs.rmSync(tempDir, {recursive: true, force: true})) + + it('should create proper directory structure for rules', () => { + const seriesDir = path.join(tempDir, 'cursor-style') + fs.mkdirSync(seriesDir, {recursive: true}) + fs.writeFileSync( + path.join(seriesDir, 'component.mdx'), + [ + 'export default {', + ' globs: [\'src/components/**\', \'**/*.tsx\'],', + ' description: \'React component conventions\'', + '}', + '', + '## Component Rules', + '', + '- Use functional components' + ].join('\n') + ) + + expect(fs.existsSync(path.join(seriesDir, 'component.mdx'))).toBe(true) + }) +}) + +describe('rule output naming', () => { + it('should generate rule- prefixed filenames for Cursor', () => { + const rule: Pick = { + series: 'cursor-style', + ruleName: 'component' + } + const fileName = `rule-${rule.series}-${rule.ruleName}.mdc` + expect(fileName).toBe('rule-cursor-style-component.mdc') + expect(fileName.startsWith('rule-')).toBe(true) + }) + + it('should generate rule- prefixed filenames for Windsurf', () => { + const rule: Pick = { + series: 'test-patterns', + ruleName: 'vitest' + } + const fileName = `rule-${rule.series}-${rule.ruleName}.md` + expect(fileName).toBe('rule-test-patterns-vitest.md') + expect(fileName.startsWith('rule-')).toBe(true) + }) + + it('should generate rule- prefixed filenames for Kiro', () => { + const rule: Pick = { + series: 'cursor-style', + ruleName: 'api' + } + const fileName = `rule-${rule.series}-${rule.ruleName}.md` + expect(fileName).toBe('rule-cursor-style-api.md') + expect(fileName.startsWith('rule-')).toBe(true) + }) + + it('should not collide with kiro- prefix used by child prompts', () => { + const ruleFileName = 'rule-cursor-style-component.md' + const childFileName = 'kiro-src-components.md' + expect(ruleFileName).not.toBe(childFileName) + expect(ruleFileName.startsWith('rule-')).toBe(true) + expect(childFileName.startsWith('kiro-')).toBe(true) + }) + + it('should not collide with trae- prefix used by child prompts', () => { + const ruleFileName = 'rule-cursor-style-component.md' + const traeFileName = 'trae-src-components.md' + expect(ruleFileName).not.toBe(traeFileName) + expect(ruleFileName.startsWith('rule-')).toBe(true) + expect(traeFileName.startsWith('trae-')).toBe(true) + }) + + it('should not collide with glob- prefix used by Qoder/JetBrains', () => { + const ruleFileName = 'rule-cursor-style-component.md' + const globFileName = 'glob-src.md' + expect(ruleFileName).not.toBe(globFileName) + expect(ruleFileName.startsWith('rule-')).toBe(true) + expect(globFileName.startsWith('glob-')).toBe(true) + }) +}) + +describe('rule scope defaults', () => { + it('should default scope to project when not provided', () => { + const scope = void 0 ?? 'project' + expect(scope).toBe('project') + }) + + it('should use explicit project scope', () => { + const scope: string = 'project' + expect(scope).toBe('project') + }) + + it('should use explicit global scope', () => { + const scope: string = 'global' + expect(scope).toBe('global') + }) +}) + +describe('kiro fileMatchPattern brace expansion', () => { + it('should use single glob directly', () => { + const globs = ['src/components/**'] + const pattern = globs.length === 1 ? globs[0] : `{${globs.join(',')}}` + expect(pattern).toBe('src/components/**') + }) + + it('should combine multiple globs with brace expansion', () => { + const globs = ['src/components/**', '**/*.tsx'] + const pattern = globs.length === 1 ? globs[0] : `{${globs.join(',')}}` + expect(pattern).toBe('{src/components/**,**/*.tsx}') + }) + + it('should handle three or more globs', () => { + const globs = ['src/**', 'lib/**', 'test/**'] + const pattern = globs.length === 1 ? globs[0] : `{${globs.join(',')}}` + expect(pattern).toBe('{src/**,lib/**,test/**}') + }) +}) diff --git a/cli/src/plugins/RuleInputPlugin.ts b/cli/src/plugins/RuleInputPlugin.ts new file mode 100644 index 00000000..d35b2379 --- /dev/null +++ b/cli/src/plugins/RuleInputPlugin.ts @@ -0,0 +1,176 @@ +import type { + CollectedInputContext, + InputPluginContext, + MetadataValidationResult, + PluginOptions, + ResolvedBasePaths, + RulePrompt, + RuleScope, + RuleYAMLFrontMatter +} from '@/types' +import {mdxToMd} from '@truenine/md-compiler' +import {MetadataValidationError} from '@truenine/md-compiler/errors' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import { + FilePathKind, + PromptKind, + validateRuleMetadata +} from '@/types' +import {BaseDirectoryInputPlugin} from './BaseDirectoryInputPlugin' + +export class RuleInputPlugin extends BaseDirectoryInputPlugin { + constructor() { + super('RuleInputPlugin', {configKey: 'shadowRulesDir'}) + } + + protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + const raw = options.shadowRulesDir + const {workspaceDir, shadowProjectDir} = resolvedPaths + return this.resolvePath(raw, workspaceDir, shadowProjectDir) + } + + protected validateMetadata(metadata: Record, filePath: string): MetadataValidationResult { + return validateRuleMetadata(metadata, filePath) + } + + protected createResult(items: RulePrompt[]): Partial { + return {rules: items} + } + + protected createPrompt( + entryName: string, + filePath: string, + content: string, + yamlFrontMatter: RuleYAMLFrontMatter | undefined, + rawFrontMatter: string | undefined, + parsed: {markdownAst?: unknown, markdownContents: readonly unknown[]}, + baseDir: string, + rawContent: string + ): RulePrompt { + const slashIndex = entryName.indexOf('/') + const series = slashIndex !== -1 ? entryName.slice(0, slashIndex) : '' + const fileName = slashIndex !== -1 ? entryName.slice(slashIndex + 1) : entryName + const ruleName = fileName.replace(/\.mdx$/, '') + + const globs: readonly string[] = yamlFrontMatter?.globs ?? [] + const scope: RuleScope = yamlFrontMatter?.scope ?? 'project' + + return { + type: PromptKind.Rule, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + ...yamlFrontMatter != null && {yamlFrontMatter}, + ...rawFrontMatter != null && {rawFrontMatter}, + markdownAst: parsed.markdownAst as never, + markdownContents: parsed.markdownContents as never, + dir: { + pathKind: FilePathKind.Relative, + path: entryName, + basePath: baseDir, + getDirectoryName: () => entryName.replace(/\.mdx$/, ''), + getAbsolutePath: () => filePath + }, + series, + ruleName, + globs, + scope, + rawMdxContent: rawContent + } + } + + override async collect(ctx: InputPluginContext): Promise> { + const {userConfigOptions: options, logger, path, fs} = ctx + const resolvedPaths = this.resolveBasePaths(options) + + const targetDir = this.getTargetDir(options, resolvedPaths) + const items: RulePrompt[] = [] + + if (!(fs.existsSync(targetDir) && fs.statSync(targetDir).isDirectory())) return this.createResult(items) + + try { + const entries = fs.readdirSync(targetDir, {withFileTypes: true}) + for (const entry of entries) { + if (entry.isDirectory()) { + const subDirPath = path.join(targetDir, entry.name) + try { + const subEntries = fs.readdirSync(subDirPath, {withFileTypes: true}) + for (const subEntry of subEntries) { + if (subEntry.isFile() && subEntry.name.endsWith(this.extension)) { + const prompt = await this.processFile(subEntry.name, path.join(subDirPath, subEntry.name), targetDir, entry.name, ctx) + if (prompt != null) items.push(prompt) + } + } + } catch (e) { + logger.error(`Failed to scan subdirectory at ${subDirPath}`, {error: e}) + } + } + } + } catch (e) { + logger.error(`Failed to scan directory at ${targetDir}`, {error: e}) + } + + return this.createResult(items) + } + + private async processFile( + fileName: string, + filePath: string, + baseDir: string, + parentDirName: string, + ctx: InputPluginContext + ): Promise { + const {logger, globalScope} = ctx + const rawContent = ctx.fs.readFileSync(filePath, 'utf8') + + try { + const parsed = parseMarkdown(rawContent) + + const compileResult = await mdxToMd(rawContent, { + globalScope, + extractMetadata: true, + basePath: ctx.path.join(baseDir, parentDirName) + }) + + const mergedFrontMatter: RuleYAMLFrontMatter | undefined = parsed.yamlFrontMatter != null || Object.keys(compileResult.metadata.fields).length > 0 + ? { + ...parsed.yamlFrontMatter, + ...compileResult.metadata.fields + } as RuleYAMLFrontMatter + : void 0 + + if (mergedFrontMatter != null) { + const validationResult = this.validateMetadata(mergedFrontMatter as Record, filePath) + + for (const warning of validationResult.warnings) logger.debug(warning) + + if (!validationResult.valid) throw new MetadataValidationError([...validationResult.errors], filePath) + } + + const {content} = compileResult + + const entryName = `${parentDirName}/${fileName}` + + logger.debug(`${this.name} metadata extracted`, { + file: entryName, + source: compileResult.metadata.source, + hasYaml: parsed.yamlFrontMatter != null, + hasExport: Object.keys(compileResult.metadata.fields).length > 0 + }) + + return this.createPrompt( + entryName, + filePath, + content, + mergedFrontMatter, + parsed.rawFrontMatter, + parsed, + baseDir, + rawContent + ) + } catch (e) { + logger.error(`failed to parse ${this.name} item`, {file: filePath, error: e}) + return void 0 + } + } +} diff --git a/cli/src/plugins/WindsurfOutputPlugin.ts b/cli/src/plugins/WindsurfOutputPlugin.ts index 59d3f399..3a0ca480 100644 --- a/cli/src/plugins/WindsurfOutputPlugin.ts +++ b/cli/src/plugins/WindsurfOutputPlugin.ts @@ -2,6 +2,7 @@ import type { FastCommandPrompt, OutputPluginContext, OutputWriteContext, + RulePrompt, SkillPrompt, WriteResult, WriteResults @@ -20,6 +21,9 @@ const MEMORIES_SUBDIR = 'memories' const GLOBAL_MEMORY_FILE = 'global_rules.md' const SKILLS_SUBDIR = 'skills' const SKILL_FILE_NAME = 'SKILL.md' +const WINDSURF_RULES_DIR = '.windsurf' +const WINDSURF_RULES_SUBDIR = 'rules' +const RULE_FILE_PREFIX = 'rule-' /** * Windsurf IDE output plugin. @@ -38,7 +42,7 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { const results: RelativePath[] = [] - const {fastCommands, skills} = ctx.collectedInputContext + const {fastCommands, skills, rules} = ctx.collectedInputContext if (fastCommands != null && fastCommands.length > 0) { const workflowsDir = this.getGlobalWorkflowsDir() @@ -65,6 +69,18 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { } } + const globalRules = rules?.filter(r => r.scope === 'global') + if (globalRules == null || globalRules.length === 0) return results + + const codeiumDir = this.getCodeiumWindsurfDir() + const memoriesDir = path.join(codeiumDir, MEMORIES_SUBDIR) + results.push({ + pathKind: FilePathKind.Relative, + path: MEMORIES_SUBDIR, + basePath: codeiumDir, + getDirectoryName: () => MEMORIES_SUBDIR, + getAbsolutePath: () => memoriesDir + }) return results } @@ -88,6 +104,23 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { } } + const globalRules = ctx.collectedInputContext.rules?.filter(r => r.scope === 'global') + if (globalRules != null && globalRules.length > 0) { + const codeiumDir = this.getCodeiumWindsurfDir() + const memoriesDir = path.join(codeiumDir, MEMORIES_SUBDIR) + for (const rule of globalRules) { + const fileName = this.buildRuleFileName(rule) + const fullPath = path.join(memoriesDir, fileName) + results.push({ + pathKind: FilePathKind.Relative, + path: path.join(MEMORIES_SUBDIR, fileName), + basePath: codeiumDir, + getDirectoryName: () => MEMORIES_SUBDIR, + getAbsolutePath: () => fullPath + }) + } + } + if (skills == null || skills.length === 0) return results const skillsDir = this.getSkillsDir() @@ -132,20 +165,21 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {skills, fastCommands, globalMemory, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + const {skills, fastCommands, globalMemory, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasSkills = (skills?.length ?? 0) > 0 const hasFastCommands = (fastCommands?.length ?? 0) > 0 + const hasRules = (rules?.length ?? 0) > 0 const hasGlobalMemory = globalMemory != null const hasCodeIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.codeignore') ?? false - if (hasSkills || hasFastCommands || hasGlobalMemory || hasCodeIgnore) return true + if (hasSkills || hasFastCommands || hasGlobalMemory || hasRules || hasCodeIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {skills, fastCommands, globalMemory} = ctx.collectedInputContext + const {skills, fastCommands, globalMemory, rules} = ctx.collectedInputContext const fileResults: WriteResult[] = [] const dirResults: WriteResult[] = [] @@ -162,18 +196,93 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { } } - if (fastCommands == null || fastCommands.length === 0) return {files: fileResults, dirs: dirResults} + if (fastCommands != null && fastCommands.length > 0) { + const workflowsDir = this.getGlobalWorkflowsDir() + for (const cmd of fastCommands) { + const result = await this.writeGlobalWorkflow(ctx, workflowsDir, cmd) + fileResults.push(result) + } + } - const workflowsDir = this.getGlobalWorkflowsDir() - for (const cmd of fastCommands) { - const result = await this.writeGlobalWorkflow(ctx, workflowsDir, cmd) - fileResults.push(result) + const globalRules = rules?.filter(r => r.scope === 'global') + if (globalRules != null && globalRules.length > 0) { + const memoriesDir = this.getGlobalMemoriesDir() + for (const rule of globalRules) { + const result = await this.writeRuleFile(ctx, memoriesDir, rule, this.getCodeiumWindsurfDir(), MEMORIES_SUBDIR) + fileResults.push(result) + } } return {files: fileResults, dirs: dirResults} } + async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {workspace, rules} = ctx.collectedInputContext + const projectRules = rules?.filter(r => r.scope === 'project') + + if (projectRules == null || projectRules.length === 0) return results + + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + const rulesDirPath = path.join(projectDir.path, WINDSURF_RULES_DIR, WINDSURF_RULES_SUBDIR) + results.push({ + pathKind: FilePathKind.Relative, + path: rulesDirPath, + basePath: projectDir.basePath, + getDirectoryName: () => WINDSURF_RULES_SUBDIR, + getAbsolutePath: () => path.join(projectDir.basePath, rulesDirPath) + }) + } + return results + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {workspace, rules} = ctx.collectedInputContext + const projectRules = rules?.filter(r => r.scope === 'project') + + if (projectRules != null && projectRules.length > 0) { + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + for (const rule of projectRules) { + const fileName = this.buildRuleFileName(rule) + const filePath = path.join(projectDir.path, WINDSURF_RULES_DIR, WINDSURF_RULES_SUBDIR, fileName) + results.push({ + pathKind: FilePathKind.Relative, + path: filePath, + basePath: projectDir.basePath, + getDirectoryName: () => WINDSURF_RULES_SUBDIR, + getAbsolutePath: () => path.join(projectDir.basePath, filePath) + }) + } + } + } + + results.push(...this.registerProjectIgnoreOutputFiles(workspace.projects)) + return results + } + async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const fileResults = await this.writeProjectIgnoreFiles(ctx) + const fileResults: WriteResult[] = [] + const {workspace, rules} = ctx.collectedInputContext + + const projectRules = rules?.filter(r => r.scope === 'project') + if (projectRules != null && projectRules.length > 0) { + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + const rulesDir = path.join(projectDir.basePath, projectDir.path, WINDSURF_RULES_DIR, WINDSURF_RULES_SUBDIR) + for (const rule of projectRules) { + const result = await this.writeRuleFile(ctx, rulesDir, rule, projectDir.basePath, path.join(projectDir.path, WINDSURF_RULES_DIR, WINDSURF_RULES_SUBDIR)) + fileResults.push(result) + } + } + } + + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) return {files: fileResults, dirs: []} } @@ -412,4 +521,57 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { return {path: relativePath, success: false, error: error as Error} } } + + private buildRuleFileName(rule: RulePrompt): string { + return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` + } + + private buildRuleContent(rule: RulePrompt): string { + const description = rule.yamlFrontMatter?.description ?? '' + const patterns = rule.globs.join(', ') + const comment = [ + ``, + ``, + '' + ].join('\n') + return `${comment}\n\n${rule.content}` + } + + private async writeRuleFile( + ctx: OutputWriteContext, + rulesDir: string, + rule: RulePrompt, + basePath: string, + relativeSubdir: string + ): Promise { + const fileName = this.buildRuleFileName(rule) + const fullPath = path.join(rulesDir, fileName) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.join(relativeSubdir, fileName), + basePath, + getDirectoryName: () => WINDSURF_RULES_SUBDIR, + getAbsolutePath: () => fullPath + } + + const content = this.buildRuleContent(rule) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'ruleFile', path: fullPath}) + return {path: relativePath, success: true, skipped: false} + } + + try { + this.ensureDirectory(rulesDir) + this.writeFileSync(fullPath, content) + this.log.trace({action: 'write', type: 'ruleFile', path: fullPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'ruleFile', path: fullPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } } diff --git a/cli/src/types/ConfigTypes.ts b/cli/src/types/ConfigTypes.ts index 604c9091..2a4372e7 100644 --- a/cli/src/types/ConfigTypes.ts +++ b/cli/src/types/ConfigTypes.ts @@ -16,6 +16,8 @@ export interface UserConfigFile { readonly shadowSubAgentDir?: string + readonly shadowRulesDir?: string + readonly globalMemoryFile?: string readonly shadowProjectsDir?: string diff --git a/cli/src/types/Enums.ts b/cli/src/types/Enums.ts index 7765425a..6b6db7b3 100644 --- a/cli/src/types/Enums.ts +++ b/cli/src/types/Enums.ts @@ -13,9 +13,15 @@ export enum PromptKind { SkillChildDoc = 'SkillChildDoc', SkillResource = 'SkillResource', SkillMcpConfig = 'SkillMcpConfig', - Readme = 'Readme' + Readme = 'Readme', + Rule = 'Rule' } +/** + * Scope for rule application + */ +export type RuleScope = 'project' | 'global' + export enum ClaudeCodeCLISubAgentColors { Red = 'Red', Green = 'Green', diff --git a/cli/src/types/ExportMetadataTypes.ts b/cli/src/types/ExportMetadataTypes.ts index 2c2814df..b5db30a4 100644 --- a/cli/src/types/ExportMetadataTypes.ts +++ b/cli/src/types/ExportMetadataTypes.ts @@ -6,7 +6,7 @@ * @module ExportMetadataTypes */ -import type {CodingAgentTools, NamingCaseKind} from './Enums' +import type {CodingAgentTools, NamingCaseKind, RuleScope} from './Enums' /** * Base export metadata interface @@ -34,6 +34,12 @@ export interface FastCommandExportMetadata extends BaseExportMetadata { readonly globalOnly?: boolean } +export interface RuleExportMetadata extends BaseExportMetadata { + readonly globs: readonly string[] + readonly description: string + readonly scope?: RuleScope +} + export interface SubAgentExportMetadata extends BaseExportMetadata { readonly name: string readonly description: string @@ -155,6 +161,34 @@ export function validateSubAgentMetadata( }) } +/** + * Validate rule export metadata + * + * @param metadata - The metadata object to validate + * @param filePath - Optional file path for error messages + * @returns Validation result + */ +export function validateRuleMetadata( + metadata: Record, + filePath?: string +): MetadataValidationResult { + const errors: string[] = [] + const warnings: string[] = [] + const prefix = filePath != null ? ` in ${filePath}` : '' + + if (!Array.isArray(metadata['globs']) || metadata['globs'].length === 0) errors.push(`Missing or empty required field "globs"${prefix}`) + else if (!metadata['globs'].every((g: unknown) => typeof g === 'string')) errors.push(`Field "globs" must be an array of strings${prefix}`) + + if (typeof metadata['description'] !== 'string' || metadata['description'].length === 0) errors.push(`Missing or empty required field "description"${prefix}`) + + const {scope} = metadata + if (scope != null && scope !== 'project' && scope !== 'global') errors.push(`Field "scope" must be "project" or "global"${prefix}`) + + if (scope == null) warnings.push(`Using default value for optional field "scope": "project"${prefix}`) + + return {valid: errors.length === 0, errors, warnings} +} + /** * Apply default values to metadata * diff --git a/cli/src/types/InputTypes.ts b/cli/src/types/InputTypes.ts index 4a989448..df19001e 100644 --- a/cli/src/types/InputTypes.ts +++ b/cli/src/types/InputTypes.ts @@ -3,13 +3,15 @@ import type { FilePathKind, GlobalMemoryPrompt, IDEKind, - PromptKind + PromptKind, + RuleScope } from '@/types/index' import type { FastCommandYAMLFrontMatter, ProjectChildrenMemoryPrompt, ProjectRootMemoryPrompt, Prompt, + RuleYAMLFrontMatter, SkillYAMLFrontMatter, SubAgentYAMLFrontMatter } from '@/types/PromptTypes' @@ -52,6 +54,7 @@ export interface CollectedInputContext { readonly fastCommands?: readonly FastCommandPrompt[] readonly subAgents?: readonly SubAgentPrompt[] readonly skills?: readonly SkillPrompt[] + readonly rules?: readonly RulePrompt[] readonly globalMemory?: GlobalMemoryPrompt readonly aiAgentIgnoreConfigFiles?: readonly AIAgentIgnoreConfigFile[] readonly globalGitIgnore?: string @@ -60,6 +63,18 @@ export interface CollectedInputContext { readonly readmePrompts?: readonly ReadmePrompt[] } +/** + * Rule prompt with glob patterns for file-scoped rule application + */ +export interface RulePrompt extends Prompt { + readonly type: PromptKind.Rule + readonly series: string + readonly ruleName: string + readonly globs: readonly string[] + readonly scope: RuleScope + readonly rawMdxContent?: string +} + /** * Fast command prompt */ diff --git a/cli/src/types/PluginTypes.ts b/cli/src/types/PluginTypes.ts index 767d8014..5ed4f3d8 100644 --- a/cli/src/types/PluginTypes.ts +++ b/cli/src/types/PluginTypes.ts @@ -384,6 +384,8 @@ export interface PluginOptions { readonly shadowSubAgentDir?: string + readonly shadowRulesDir?: string + readonly globalMemoryFile?: string readonly shadowProjectsDir?: string diff --git a/cli/src/types/PromptTypes.ts b/cli/src/types/PromptTypes.ts index e9dd0e95..134b2027 100644 --- a/cli/src/types/PromptTypes.ts +++ b/cli/src/types/PromptTypes.ts @@ -1,5 +1,5 @@ import type {Root, RootContent} from '@truenine/md-compiler' -import type {ClaudeCodeCLISubAgentColors, CodingAgentTools, FilePathKind, NamingCaseKind, PromptKind} from '@/types/Enums' +import type {ClaudeCodeCLISubAgentColors, CodingAgentTools, FilePathKind, NamingCaseKind, PromptKind, RuleScope} from '@/types/Enums' import type {FileContent, Path, RelativePath, RootPath} from '@/types/FileSystemTypes' import type {GlobalConfigDirectory} from '@/types/OutputTypes' @@ -122,6 +122,14 @@ export interface KiroPowerYAMLFrontMatter extends SkillsYAMLFrontMatter { readonly author?: string } +/** + * Rule YAML front matter with glob patterns and scope + */ +export interface RuleYAMLFrontMatter extends CommonYAMLFrontMatter { + readonly globs: readonly string[] + readonly scope?: RuleScope +} + /** * Global memory prompt * Single output target diff --git a/gui/package.json b/gui/package.json index b346908e..a2e91442 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10214.1083059", + "version": "2026.10217.12221", "private": true, "engines": { "node": ">=25.2.1", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index cd1108cd..dd63f5c3 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memory-sync-gui" -version = "2026.10214.1083059" +version = "2026.10217.12221" description = "Memory Sync desktop GUI application" authors = ["TrueNine"] edition = "2021" diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 8abd1999..f6766336 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "version": "2026.10214.1083059", + "version": "2026.10217.12221", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/package.json b/package.json index 2e2521f3..205a521f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync", - "version": "2026.10214.1083059", + "version": "2026.10217.12221", "description": "Cross-AI-tool prompt synchronisation toolkit (CLI + Tauri desktop GUI) — one ruleset, multi-target adaptation. Monorepo powered by pnpm + Turbo.", "license": "AGPL-3.0-only", "keywords": [ diff --git a/packages/init-bundle/public/public/tnmsc.example.json b/packages/init-bundle/public/public/tnmsc.example.json index 4e3a7c56..25b5a60d 100644 --- a/packages/init-bundle/public/public/tnmsc.example.json +++ b/packages/init-bundle/public/public/tnmsc.example.json @@ -4,6 +4,7 @@ "shadowSkillSourceDir": "$SHADOW_SOURCE_PROJECT/dist/skills", "shadowFastCommandDir": "$SHADOW_SOURCE_PROJECT/dist/commands", "shadowSubAgentDir": "$SHADOW_SOURCE_PROJECT/dist/agents", + "shadowRulesDir": "$SHADOW_SOURCE_PROJECT/dist/rules", "globalMemoryFile": "$SHADOW_SOURCE_PROJECT/dist/global.mdx", "shadowProjectsDir": "$SHADOW_SOURCE_PROJECT/dist/app", "externalProjects": [],