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 { 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 deleted file mode 100644 index a448e8c7..00000000 --- a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.test.ts +++ /dev/null @@ -1,364 +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'} - ] - } - - 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(5) - 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') - ]) - }) - - 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(5) // 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') - ]) - }) - - 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(10) - expect(results.map(r => r.path)).toContain(path.join('project1', '.qoderignore')) - expect(results.map(r => r.path)).toContain(path.join('project2', '.qoderignore')) - }) - - 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(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.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(5) - expect(results.files.every(r => r.success)).toBe(true) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(5) - }) - - 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' - ) - }) - - it('should not ensure directory exists (files written to project root)', 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 - }) - - 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(5) - expect(results.files.every(r => r.success && r.skipped === false)).toBe(true) - expect(vi.mocked(fs.writeFileSync)).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(5) - 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(10) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(10) - }) - }) -}) diff --git a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts b/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts deleted file mode 100644 index ca03c037..00000000 --- a/cli/src/plugins/AIAgentIgnoreConfigFileOutputPlugin.ts +++ /dev/null @@ -1,123 +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' - -/** - * All ignore file names that this plugin manages - */ -const IGNORE_FILE_NAMES = ['.qoderignore', '.cursorignore', '.kiroignore', '.warpindexignore', '.aiignore'] 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 fileName of IGNORE_FILE_NAMES) { // Register all possible ignore files for cleanup - const filePath = path.join(project.dirFromWorkspacePath.path, fileName) - 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 async writeIgnoreFile( - ctx: OutputWriteContext, - projectDir: RelativePath, - ignoreFile: {fileName: string, content: string}, - label: string - ): Promise { - const filePath = path.join(projectDir.path, ignoreFile.fileName) - 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 { - 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..c211befd 100644 --- a/cli/src/plugins/CursorOutputPlugin.ts +++ b/cli/src/plugins/CursorOutputPlugin.ts @@ -3,6 +3,7 @@ import type { OutputPluginContext, OutputWriteContext, Project, + RulePrompt, SkillPrompt, WriteResult, WriteResults @@ -22,6 +23,7 @@ const RULES_SUBDIR = 'rules' const GLOBAL_RULE_FILE = 'global.mdc' const SKILLS_CURSOR_SUBDIR = 'skills-cursor' const SKILL_FILE_NAME = 'SKILL.md' +const RULE_FILE_PREFIX = 'rule-' const PRESERVED_SKILLS = new Set([ 'create-rule', @@ -44,7 +46,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 => { @@ -74,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() @@ -103,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 } @@ -139,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() @@ -195,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 @@ -208,33 +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} = 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) 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[] = [] @@ -252,29 +304,56 @@ 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 - if (globalMemory == null) return {files: fileResults, dirs: dirResults} + const {workspace, globalMemory, rules} = ctx.collectedInputContext + 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 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 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) + return {files: fileResults, dirs: dirResults} } @@ -655,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/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..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,10 +24,11 @@ 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() { - 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) @@ -77,18 +79,36 @@ 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 + )) + } } } + + results.push(...this.registerProjectIgnoreOutputFiles(projects)) return results } @@ -117,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[] = [] @@ -127,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() @@ -176,26 +201,42 @@ export class KiroCLIOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, globalMemory, fastCommands, skills} = ctx.collectedInputContext + const {workspace, globalMemory, fastCommands, skills, rules, 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 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 || 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) + fileResults.push(...ignoreResults) + return {files: fileResults, dirs: []} } 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() @@ -208,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) @@ -347,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/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) { 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/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/TraeIDEOutputPlugin.test.ts b/cli/src/plugins/TraeIDEOutputPlugin.test.ts new file mode 100644 index 00000000..3ca50ba7 --- /dev/null +++ b/cli/src/plugins/TraeIDEOutputPlugin.test.ts @@ -0,0 +1,136 @@ +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! + + 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 new file mode 100644 index 00000000..64ea3966 --- /dev/null +++ b/cli/src/plugins/TraeIDEOutputPlugin.ts @@ -0,0 +1,158 @@ +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, indexignore: '.traeignore'}) + } + + 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 + )) + } + } + + results.push(...this.registerProjectIgnoreOutputFiles(projects)) + 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, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + const hasChildPrompts = workspace.projects.some(p => (p.childMemoryPrompts?.length ?? 0) > 0) + 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 + } + + 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)) + } + + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) + + 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') + } +} 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..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. @@ -31,13 +35,14 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { super('WindsurfOutputPlugin', { globalConfigDir: CODEIUM_WINDSURF_DIR, outputFileName: '', - dependsOn: ['AgentsOutputPlugin'] + dependsOn: ['AgentsOutputPlugin'], + indexignore: '.codeignore' }) } 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() @@ -64,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 } @@ -87,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() @@ -131,19 +165,21 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {skills, fastCommands, globalMemory} = 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) 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[] = [] @@ -160,18 +196,94 @@ 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 writeProjectOutputs(): Promise { - return {files: [], dirs: []} + 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: 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: []} } private getSkillsDir(): string { @@ -409,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/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/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 148aaa78..a2e91442 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,7 +1,12 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10214.1083059", + "version": "2026.10217.12221", "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) + 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": [],