From d9659fb7017cb40968ab568406cd601eda3cbc7a Mon Sep 17 00:00:00 2001 From: TrueNine Date: Sun, 1 Mar 2026 10:33:43 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0TNMS?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F=E6=A0=B8=E5=BF=83=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现配置系统的核心功能,包括: - 配置文件解析与验证 - 路径解析工具 - 配置服务单例 - 类型定义与错误处理 - 单元测试覆盖 --- cli/src/config/ConfigService.test.ts | 336 ++++++++++++++++++++++++++ cli/src/config/ConfigService.ts | 231 ++++++++++++++++++ cli/src/config/accessors.ts | 245 +++++++++++++++++++ cli/src/config/errors.ts | 164 +++++++++++++ cli/src/config/example.json | 50 ++++ cli/src/config/index.ts | 86 +++++++ cli/src/config/pathResolver.test.ts | 348 +++++++++++++++++++++++++++ cli/src/config/pathResolver.ts | 232 ++++++++++++++++++ cli/src/config/schema.test.ts | 293 ++++++++++++++++++++++ cli/src/config/schema.ts | 158 ++++++++++++ cli/src/config/types.ts | 114 +++++++++ 11 files changed, 2257 insertions(+) create mode 100644 cli/src/config/ConfigService.test.ts create mode 100644 cli/src/config/ConfigService.ts create mode 100644 cli/src/config/accessors.ts create mode 100644 cli/src/config/errors.ts create mode 100644 cli/src/config/example.json create mode 100644 cli/src/config/index.ts create mode 100644 cli/src/config/pathResolver.test.ts create mode 100644 cli/src/config/pathResolver.ts create mode 100644 cli/src/config/schema.test.ts create mode 100644 cli/src/config/schema.ts create mode 100644 cli/src/config/types.ts diff --git a/cli/src/config/ConfigService.test.ts b/cli/src/config/ConfigService.test.ts new file mode 100644 index 00000000..85050217 --- /dev/null +++ b/cli/src/config/ConfigService.test.ts @@ -0,0 +1,336 @@ +/** + * Unit tests for ConfigService + */ + +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 {ConfigService, getDefaultConfigPath} from './ConfigService' +import { + ConfigFileNotFoundError, + ConfigParseError, + ConfigValidationError +} from './errors' + +describe('configService', () => { + let tempDir: string, + configService: ConfigService + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-config-test-')) + ConfigService.resetInstance() + configService = ConfigService.getInstance({configPath: path.join(tempDir, '.tnmsc.json')}) + }) + + afterEach(() => { + ConfigService.resetInstance() + try { // Clean up temp directory + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch { + } // Ignore cleanup errors + }) + + describe('singleton pattern', () => { + it('should return the same instance', () => { + const instance1 = ConfigService.getInstance() + const instance2 = ConfigService.getInstance() + expect(instance1).toBe(instance2) + }) + + it('should create new instance after reset', () => { + const instance1 = ConfigService.getInstance() + ConfigService.resetInstance() + const instance2 = ConfigService.getInstance() + expect(instance1).not.toBe(instance2) + }) + }) + + describe('load', () => { + it('should load valid configuration', () => { + const validConfig = { + version: '2026.10218.12101', + workspaceDir: '~/project', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test User', + username: 'testuser', + gender: 'male', + birthday: '1990-01-01' + } + } + + fs.writeFileSync(configService.getConfigPath(), JSON.stringify(validConfig, null, 2)) + + const config = configService.load() + + expect(config.version).toBe('2026.10218.12101') + expect(config.workspaceDir).toBe('~/project') + expect(config.logLevel).toBe('info') + expect(config.profile.name).toBe('Test User') + expect(config.aindex.name).toBe('aindex') + expect(config.aindex.skills.src).toBe('skills') + }) + + it('should throw ConfigFileNotFoundError for missing file', () => { + expect(() => configService.load()).toThrow(ConfigFileNotFoundError) + }) + + it('should throw ConfigParseError for invalid JSON', () => { + fs.writeFileSync(configService.getConfigPath(), 'not valid json') + expect(() => configService.load()).toThrow(ConfigParseError) + }) + + it('should throw ConfigValidationError for missing required fields', () => { + const invalidConfig = { + version: '2026.10218.12101' + } // missing workspaceDir, aindex, logLevel, profile + + fs.writeFileSync(configService.getConfigPath(), JSON.stringify(invalidConfig)) + expect(() => configService.load()).toThrow(ConfigValidationError) + }) + + it('should throw ConfigValidationError for invalid version format', () => { + const invalidConfig = { + version: 'invalid-version', + workspaceDir: '~/project', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test User', + username: 'testuser', + gender: 'male', + birthday: '1990-01-01' + } + } + + fs.writeFileSync(configService.getConfigPath(), JSON.stringify(invalidConfig)) + expect(() => configService.load()).toThrow(ConfigValidationError) + }) + + it('should throw ConfigValidationError for invalid logLevel', () => { + const invalidConfig = { + version: '2026.10218.12101', + workspaceDir: '~/project', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'invalid-level', + profile: { + name: 'Test User', + username: 'testuser', + gender: 'male', + birthday: '1990-01-01' + } + } + + fs.writeFileSync(configService.getConfigPath(), JSON.stringify(invalidConfig)) + expect(() => configService.load()).toThrow(ConfigValidationError) + }) + }) + + describe('safeLoad', () => { + it('should return config when file exists', () => { + const validConfig = { + version: '2026.10218.12101', + workspaceDir: '~/project', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test User', + username: 'testuser', + gender: 'male', + birthday: '1990-01-01' + } + } + + fs.writeFileSync(configService.getConfigPath(), JSON.stringify(validConfig, null, 2)) + + const result = configService.safeLoad() + + expect(result.found).toBe(true) + expect(result.source).toBe(configService.getConfigPath()) + expect(result.config.version).toBe('2026.10218.12101') + }) + + it('should return default config when file not found', () => { + const result = configService.safeLoad() + + expect(result.found).toBe(false) + expect(result.config).toBeDefined() + expect(result.config.workspaceDir).toBe('~/project') + }) + + it('should throw for invalid JSON even in safeLoad', () => { + fs.writeFileSync(configService.getConfigPath(), 'not valid json') + expect(() => configService.safeLoad()).toThrow(ConfigParseError) + }) + }) + + describe('reload', () => { + it('should reload configuration from disk', () => { + const config1 = { + version: '2026.10218.12101', + workspaceDir: '~/project', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test User', + username: 'testuser', + gender: 'male', + birthday: '1990-01-01' + } + } + + fs.writeFileSync(configService.getConfigPath(), JSON.stringify(config1)) + configService.load() + + const config2 = { + ...config1, + version: '2026.10219.00000' + } + + fs.writeFileSync(configService.getConfigPath(), JSON.stringify(config2)) + const reloaded = configService.reload() + + expect(reloaded.version).toBe('2026.10219.00000') + }) + }) + + describe('getConfig', () => { + it('should return loaded configuration', () => { + const validConfig = { + version: '2026.10218.12101', + workspaceDir: '~/project', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test User', + username: 'testuser', + gender: 'male', + birthday: '1990-01-01' + } + } + + fs.writeFileSync(configService.getConfigPath(), JSON.stringify(validConfig)) + configService.load() + + const config = configService.getConfig() + expect(config.version).toBe('2026.10218.12101') + }) + + it('should throw if configuration not loaded', () => { + expect(() => configService.getConfig()).toThrow('Configuration has not been loaded') + }) + }) + + describe('isLoaded', () => { + it('should return false before loading', () => expect(configService.isLoaded()).toBe(false)) + + it('should return true after loading', () => { + const validConfig = { + version: '2026.10218.12101', + workspaceDir: '~/project', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test User', + username: 'testuser', + gender: 'male', + birthday: '1990-01-01' + } + } + + fs.writeFileSync(configService.getConfigPath(), JSON.stringify(validConfig)) + configService.load() + + expect(configService.isLoaded()).toBe(true) + }) + }) + + describe('getDefaultConfigPath', () => { + it('should return path in home directory', () => { + const defaultPath = getDefaultConfigPath() + expect(defaultPath).toContain('.aindex') + expect(defaultPath).toContain('.tnmsc.json') + expect(path.isAbsolute(defaultPath)).toBe(true) + }) + }) +}) diff --git a/cli/src/config/ConfigService.ts b/cli/src/config/ConfigService.ts new file mode 100644 index 00000000..79f8c1b0 --- /dev/null +++ b/cli/src/config/ConfigService.ts @@ -0,0 +1,231 @@ +/** + * Configuration service for the TNMSC configuration system. + * + * This module provides a singleton service for loading, validating, + * and accessing configuration from ~/.aindex/.tnmsc.json + */ + +import type {ConfigLoadResult, ConfigServiceOptions, TnmscConfig} from './types' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { + ConfigError, + ConfigFileNotFoundError, + ConfigParseError, + ConfigPermissionError, + ConfigValidationError +} from './errors' +import {clearPathCache} from './pathResolver' +import {validateConfig} from './schema' + +/** + * Default configuration file name. + */ +export const DEFAULT_CONFIG_FILE_NAME = '.tnmsc.json' + +/** + * Default global configuration directory (relative to home). + */ +export const DEFAULT_GLOBAL_CONFIG_DIR = '.aindex' + +/** + * Get the default global configuration file path. + * + * @returns The absolute path to ~/.aindex/.tnmsc.json + */ +export function getDefaultConfigPath(): string { + return path.join(os.homedir(), DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_CONFIG_FILE_NAME) +} + +/** + * Configuration service singleton for managing TNMSC configuration. + * + * This service provides: + * - Singleton access to configuration across the application + * - Automatic validation of configuration files + * - Runtime configuration reloading + * - Comprehensive error handling + */ +export class ConfigService { + private static instance: ConfigService | null = null + + private config: TnmscConfig | null = null + private configPath: string + private loadError: ConfigError | null = null + + private constructor(options: ConfigServiceOptions = {}) { + this.configPath = options.configPath ?? getDefaultConfigPath() + } + + static getInstance(options?: ConfigServiceOptions): ConfigService { + ConfigService.instance ??= new ConfigService(options) + return ConfigService.instance + } + + static resetInstance(): void { + ConfigService.instance = null + } + + load(): TnmscConfig { + this.loadError = null + + if (!fs.existsSync(this.configPath)) { // Check if file exists + this.loadError = new ConfigFileNotFoundError(this.configPath) + throw this.loadError + } + + let content: string // Read file content + try { + content = fs.readFileSync(this.configPath, 'utf8') + } + catch (error) { + const configError = new ConfigPermissionError( + this.configPath, + error instanceof Error ? error : new Error(String(error)) + ) + this.loadError = configError + throw configError + } + + let parsed: unknown // Parse JSON + try { + parsed = JSON.parse(content) + } + catch (error) { + if (error instanceof SyntaxError) { + const configError = new ConfigParseError(this.configPath, error) + this.loadError = configError + throw configError + } + throw error + } + + try { // Validate configuration + this.config = validateConfig(parsed) + clearPathCache() // Clear path cache when config is reloaded + return this.config + } + catch (error) { + if (error instanceof Error && error.name === 'ZodError') { + const zodError = error as unknown as {issues: {path: (string | number)[], message: string}[]} + const validationErrors = zodError.issues.map( + issue => `${issue.path.join('.')}: ${issue.message}` + ) + const configError = new ConfigValidationError(this.configPath, validationErrors) + this.loadError = configError + throw configError + } + throw error + } + } + + safeLoad(): ConfigLoadResult { + try { + const config = this.load() + return { + config, + source: this.configPath, + found: true + } + } + catch (error) { + if (error instanceof ConfigFileNotFoundError) { + return { // Return a default-like config for missing files + config: this.getDefaultConfig(), + source: this.configPath, + found: false + } + } + throw error + } + } + + reload(): TnmscConfig { + this.config = null + return this.load() + } + + getConfig(): TnmscConfig { + if (this.config === null) { + throw new ConfigError( + 'Configuration has not been loaded. Call load() first.', + this.configPath + ) + } + return this.config + } + + isLoaded(): boolean { + return this.config !== null + } + + getLastError(): ConfigError | null { + return this.loadError + } + + getConfigPath(): string { + return this.configPath + } + + setConfigPath(configPath: string): void { + this.configPath = configPath + this.config = null // Reset loaded config + this.loadError = null + } + + private getDefaultConfig(): TnmscConfig { + return { + version: '2026.00000.00000', + workspaceDir: '~/project', + logLevel: 'info', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + profile: { + name: '', + username: '', + gender: '', + birthday: '' + } + } + } +} + +/** + * Convenience function to get the ConfigService singleton instance. + * + * @param options - Optional configuration options + * @returns The ConfigService instance + */ +export function getConfigService(options?: ConfigServiceOptions): ConfigService { + return ConfigService.getInstance(options) +} + +/** + * Load configuration using the default ConfigService instance. + * + * @returns The loaded configuration + * @throws {ConfigError} If loading or validation fails + */ +export function loadConfig(): TnmscConfig { + return getConfigService().load() +} + +/** + * Safely load configuration using the default ConfigService instance. + * + * @returns The load result with success flag + */ +export function safeLoadConfig(): ConfigLoadResult { + return getConfigService().safeLoad() +} diff --git a/cli/src/config/accessors.ts b/cli/src/config/accessors.ts new file mode 100644 index 00000000..5fae229b --- /dev/null +++ b/cli/src/config/accessors.ts @@ -0,0 +1,245 @@ +/** + * Configuration accessor functions for the TNMSC configuration system. + * + * This module provides convenient accessor functions for retrieving + * specific configuration values and resolved paths. + */ + +import type { + LogLevel, + ModulePaths, + Profile, + ResolvedModulePaths, + TnmscConfig +} from './types' +import {ConfigService} from './ConfigService' +import { + getAbsoluteWorkspaceDir, + getAindexModulePaths as resolveAindexModulePaths, + resolveAllAindexPaths +} from './pathResolver' + +/** + * Get the configuration from the default ConfigService instance. + * + * @returns The current configuration + * @throws {ConfigError} If configuration hasn't been loaded + */ +export function getConfig(): TnmscConfig { + return ConfigService.getInstance().getConfig() +} + +export function getVersion(config?: TnmscConfig): string { + const cfg = config ?? getConfig() + return cfg.version +} + +/** + * Get the workspace directory from the configuration. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The workspace directory path (with ~ expanded) + */ +export function getWorkspaceDir(config?: TnmscConfig): string { + const cfg = config ?? getConfig() + return cfg.workspaceDir +} + +/** + * Get the absolute workspace directory path. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The absolute workspace directory path + */ +export function getAbsoluteWorkspaceDirPath(config?: TnmscConfig): string { + const cfg = config ?? getConfig() + return getAbsoluteWorkspaceDir(cfg.workspaceDir) +} + +/** + * Get the log level from the configuration. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The log level setting + */ +export function getLogLevel(config?: TnmscConfig): LogLevel { + const cfg = config ?? getConfig() + return cfg.logLevel +} + +/** + * Get the profile information from the configuration. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The user profile + */ +export function getProfile(config?: TnmscConfig): Profile { + const cfg = config ?? getConfig() + return cfg.profile +} + +/** + * Get the aindex configuration. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The aindex configuration + */ +export function getAindexConfig(config?: TnmscConfig): TnmscConfig['aindex'] { + const cfg = config ?? getConfig() + return cfg.aindex +} + +/** + * Get a specific aindex module's paths. + * + * @param moduleName - The name of the module (e.g., 'skills', 'commands') + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The module's src/dist paths + */ +export function getAindexModulePaths( + moduleName: keyof TnmscConfig['aindex'] & string, + config?: TnmscConfig +): ModulePaths { + const cfg = config ?? getConfig() + const modulePaths = cfg.aindex[moduleName] + + if (modulePaths === void 0 || modulePaths === null || typeof modulePaths !== 'object' || !('src' in modulePaths)) { + throw new Error(`Invalid aindex module: ${moduleName}`) + } + + return modulePaths +} + +/** + * Get a specific aindex module's resolved paths (absolute and relative). + * + * @param moduleName - The name of the module (e.g., 'skills', 'commands') + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The resolved module paths + */ +export function getResolvedAindexModulePaths( + moduleName: keyof TnmscConfig['aindex'] & string, + config?: TnmscConfig +): ResolvedModulePaths { + const cfg = config ?? getConfig() + return resolveAindexModulePaths(cfg, moduleName) +} + +/** + * Get all resolved aindex module paths. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns Object with all module paths resolved + */ +export function getAllResolvedAindexPaths(config?: TnmscConfig): ReturnType { + const cfg = config ?? getConfig() + return resolveAllAindexPaths(cfg) +} + +/** + * Get the skills module paths. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The skills module paths + */ +export function getSkillsPaths(config?: TnmscConfig): ModulePaths { + return getAindexModulePaths('skills', config) +} + +/** + * Get the commands module paths. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The commands module paths + */ +export function getCommandsPaths(config?: TnmscConfig): ModulePaths { + return getAindexModulePaths('commands', config) +} + +/** + * Get the sub-agents module paths. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The sub-agents module paths + */ +export function getSubAgentsPaths(config?: TnmscConfig): ModulePaths { + return getAindexModulePaths('subAgents', config) +} + +/** + * Get the rules module paths. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The rules module paths + */ +export function getRulesPaths(config?: TnmscConfig): ModulePaths { + return getAindexModulePaths('rules', config) +} + +/** + * Get the global prompt file paths. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The global prompt file paths + */ +export function getGlobalPromptPaths(config?: TnmscConfig): ModulePaths { + return getAindexModulePaths('globalPrompt', config) +} + +/** + * Get the workspace prompt file paths. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The workspace prompt file paths + */ +export function getWorkspacePromptPaths(config?: TnmscConfig): ModulePaths { + return getAindexModulePaths('workspacePrompt', config) +} + +/** + * Get the app module paths. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The app module paths + */ +export function getAppPaths(config?: TnmscConfig): ModulePaths { + return getAindexModulePaths('app', config) +} + +/** + * Get the ext module paths. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The ext module paths + */ +export function getExtPaths(config?: TnmscConfig): ModulePaths { + return getAindexModulePaths('ext', config) +} + +/** + * Get the arch module paths. + * + * @param config - Optional configuration object (uses loaded config if not provided) + * @returns The arch module paths + */ +export function getArchPaths(config?: TnmscConfig): ModulePaths { + return getAindexModulePaths('arch', config) +} + +/** + * Check if the configuration has been loaded. + * + * @returns True if configuration is loaded + */ +export function isConfigLoaded(): boolean { + return ConfigService.getInstance().isLoaded() +} + +/** + * Reload the configuration from disk. + * + * @returns The reloaded configuration + */ +export function reloadConfig(): TnmscConfig { + return ConfigService.getInstance().reload() +} diff --git a/cli/src/config/errors.ts b/cli/src/config/errors.ts new file mode 100644 index 00000000..ca408f69 --- /dev/null +++ b/cli/src/config/errors.ts @@ -0,0 +1,164 @@ +/** + * Error classes for the TNMSC configuration system. + * + * This module provides specific error types for different configuration + * failure scenarios, enabling better error handling and user feedback. + */ + +/** + * Base error class for all configuration-related errors. + */ +export class ConfigError extends Error { + readonly configPath: string | undefined + + constructor(message: string, configPath?: string) { + super(message) + this.name = 'ConfigError' + this.configPath = configPath ?? void 0 + + if (Error.captureStackTrace !== void 0 && Error.captureStackTrace !== null) { // Maintain proper stack trace in V8 environments + Error.captureStackTrace(this, ConfigError) + } + } + + override toString(): string { + const pathInfo = this.configPath !== void 0 && this.configPath !== null && this.configPath.length > 0 ? ` (${this.configPath})` : '' + return `${this.name}${pathInfo}: ${this.message}` + } +} + +/** + * Error thrown when the configuration file cannot be found. + */ +export class ConfigFileNotFoundError extends ConfigError { + constructor(configPath: string) { + super(`Configuration file not found: ${configPath}`, configPath) + this.name = 'ConfigFileNotFoundError' + + if (Error.captureStackTrace !== void 0 && Error.captureStackTrace !== null) Error.captureStackTrace(this, ConfigFileNotFoundError) + } +} + +/** + * Error thrown when the configuration file contains invalid JSON. + */ +export class ConfigParseError extends ConfigError { + readonly syntaxError: SyntaxError + + constructor(configPath: string, syntaxError: SyntaxError) { + super(`Invalid JSON in configuration file: ${syntaxError.message}`, configPath) + this.name = 'ConfigParseError' + this.syntaxError = syntaxError + + if (Error.captureStackTrace !== void 0 && Error.captureStackTrace !== null) Error.captureStackTrace(this, ConfigParseError) + } +} + +/** + * Error thrown when the configuration fails schema validation. + */ +export class ConfigValidationError extends ConfigError { + readonly validationErrors: readonly string[] + + constructor(configPath: string, validationErrors: string[]) { + const errorList = validationErrors.join('; ') + super(`Configuration validation failed: ${errorList}`, configPath) + this.name = 'ConfigValidationError' + this.validationErrors = validationErrors + + if (Error.captureStackTrace !== void 0 && Error.captureStackTrace !== null) Error.captureStackTrace(this, ConfigValidationError) + } + + get formattedErrors(): string { + return this.validationErrors.map((err, i) => ` ${i + 1}. ${err}`).join('\n') + } + + override toString(): string { + const pathInfo = this.configPath !== void 0 && this.configPath !== null && this.configPath.length > 0 ? ` (${this.configPath})` : '' + return `${this.name}${pathInfo}:\n${this.formattedErrors}` + } +} + +/** + * Error thrown when path resolution fails. + */ +export class ConfigPathError extends ConfigError { + readonly path: string + + constructor(configPath: string, path: string, reason: string) { + super(`Path resolution failed for "${path}": ${reason}`, configPath) + this.name = 'ConfigPathError' + this.path = path + + if (Error.captureStackTrace !== void 0 && Error.captureStackTrace !== null) Error.captureStackTrace(this, ConfigPathError) + } +} + +/** + * Error thrown when the configuration file cannot be read due to permissions. + */ +export class ConfigPermissionError extends ConfigError { + readonly originalError: Error + + constructor(configPath: string, originalError: Error) { + super(`Cannot read configuration file: ${originalError.message}`, configPath) + this.name = 'ConfigPermissionError' + this.originalError = originalError + + if (Error.captureStackTrace !== void 0 && Error.captureStackTrace !== null) Error.captureStackTrace(this, ConfigPermissionError) + } +} + +/** + * Type guard to check if an error is a ConfigError. + * + * @param error - The error to check + * @returns True if the error is a ConfigError + */ +export function isConfigError(error: unknown): error is ConfigError { + return error instanceof ConfigError +} + +/** + * Type guard to check if an error is a ConfigFileNotFoundError. + * + * @param error - The error to check + * @returns True if the error is a ConfigFileNotFoundError + */ +export function isConfigFileNotFoundError(error: unknown): error is ConfigFileNotFoundError { + return error instanceof ConfigFileNotFoundError +} + +/** + * Type guard to check if an error is a ConfigParseError. + * + * @param error - The error to check + * @returns True if the error is a ConfigParseError + */ +export function isConfigParseError(error: unknown): error is ConfigParseError { + return error instanceof ConfigParseError +} + +/** + * Type guard to check if an error is a ConfigValidationError. + * + * @param error - The error to check + * @returns True if the error is a ConfigValidationError + */ +export function isConfigValidationError(error: unknown): error is ConfigValidationError { + return error instanceof ConfigValidationError +} + +/** + * Format any error into a user-friendly message. + * + * @param error - The error to format + * @returns A formatted error message + */ +export function formatConfigError(error: unknown): string { + if (isConfigError(error)) return error.toString() + + if (error instanceof Error) return `Error: ${error.message}` + + return `Unknown error: ${String(error)}` +} diff --git a/cli/src/config/example.json b/cli/src/config/example.json new file mode 100644 index 00000000..2bd83bdb --- /dev/null +++ b/cli/src/config/example.json @@ -0,0 +1,50 @@ +{ + "version": "2026.10218.12101", + "workspaceDir": "~/project", + "aindex": { + "name": "aindex", + "skills": { + "src": "skills", + "dist": "dist/skills" + }, + "commands": { + "src": "commands", + "dist": "dist/commands" + }, + "subAgents": { + "src": "agents", + "dist": "dist/agents" + }, + "rules": { + "src": "rules", + "dist": "dist/rules" + }, + "globalPrompt": { + "src": "app/global.cn.mdx", + "dist": "dist/global.mdx" + }, + "workspacePrompt": { + "src": "app/workspace.cn.mdx", + "dist": "dist/workspace.mdx" + }, + "app": { + "src": "app", + "dist": "dist/app" + }, + "ext": { + "src": "ext", + "dist": "dist/ext" + }, + "arch": { + "src": "arch", + "dist": "dist/arch" + } + }, + "logLevel": "info", + "profile": { + "name": "赵日天", + "username": "TrueNine", + "gender": "male", + "birthday": "1997-11-04" + } +} diff --git a/cli/src/config/index.ts b/cli/src/config/index.ts new file mode 100644 index 00000000..63a8efc6 --- /dev/null +++ b/cli/src/config/index.ts @@ -0,0 +1,86 @@ +export { // Export accessor functions + getAbsoluteWorkspaceDirPath, + getAindexConfig, + getAllResolvedAindexPaths, + getAppPaths, + getArchPaths, + getCommandsPaths, + getConfig, + getExtPaths, + getGlobalPromptPaths, + getLogLevel, + getAindexModulePaths as getModulePaths, + getProfile, + getResolvedAindexModulePaths, + getRulesPaths, + getSkillsPaths, + getSubAgentsPaths, + getVersion, + getWorkspaceDir, + getWorkspacePromptPaths, + isConfigLoaded, + reloadConfig +} from './accessors' + +export { // Export configuration service + ConfigService, + DEFAULT_CONFIG_FILE_NAME, + DEFAULT_GLOBAL_CONFIG_DIR, + getConfigService, + getDefaultConfigPath, + loadConfig, + safeLoadConfig +} from './ConfigService' + +export { // Export error classes + ConfigError, + ConfigFileNotFoundError, + ConfigParseError, + ConfigPathError, + ConfigPermissionError, + ConfigValidationError, + formatConfigError, + isConfigError, + isConfigFileNotFoundError, + isConfigParseError, + isConfigValidationError +} from './errors' + +export { // Export path resolution utilities + clearPathCache, + expandHomeDir, + getAbsoluteDistPath, + getAbsoluteSrcPath, + getAbsoluteWorkspaceDir, + getAindexModulePaths, + getRelativePath, + isAbsolutePath, + joinPath, + normalizePath, + resolveAllAindexPaths, + resolveModulePaths, + resolveWorkspacePath +} from './pathResolver' + +export { // Export schema and validation + formatValidationErrors, + getDefaultConfig, + isValidLogLevel, + safeValidateConfig, + validateConfig, + ZAindexConfig, + ZModulePaths, + ZProfile, + ZTnmscConfig +} from './schema' + +export type { // Export types + AindexConfig, + ConfigLoadResult, + ConfigServiceOptions, + LogLevel, + ModulePaths, + Profile, + ResolvedModulePaths, + TnmscConfig +} from './types' diff --git a/cli/src/config/pathResolver.test.ts b/cli/src/config/pathResolver.test.ts new file mode 100644 index 00000000..17803722 --- /dev/null +++ b/cli/src/config/pathResolver.test.ts @@ -0,0 +1,348 @@ +/** + * Unit tests for pathResolver + */ + +import type {TnmscConfig} from './types' +import * as os from 'node:os' +import * as path from 'node:path' +import {beforeEach, describe, expect, it} from 'vitest' +import { + clearPathCache, + expandHomeDir, + getAbsoluteDistPath, + getAbsoluteSrcPath, + getAbsoluteWorkspaceDir, + getAindexModulePaths, + getRelativePath, + isAbsolutePath, + joinPath, + normalizePath, + resolveAllAindexPaths, + resolveModulePaths, + resolveWorkspacePath +} from './pathResolver' + +describe('pathResolver', () => { + beforeEach(() => clearPathCache()) + + describe('expandHomeDir', () => { + it('should expand ~ to home directory', () => { + const result = expandHomeDir('~/project') + expect(result).toBe(path.join(os.homedir(), 'project')) + }) + + it('should handle ~ alone', () => { + const result = expandHomeDir('~') + expect(result).toBe(os.homedir()) + }) + + it('should not modify paths without ~', () => { + const absolutePath = '/some/absolute/path' + const result = expandHomeDir(absolutePath) + expect(result).toBe(absolutePath) + }) + + it('should handle Windows-style home paths', () => { + const result = expandHomeDir('~\\project') + expect(result).toBe(path.join(os.homedir(), 'project')) + }) + + it('should return path as-is for ~username syntax', () => { + const result = expandHomeDir('~otheruser/project') + expect(result).toBe('~otheruser/project') + }) + }) + + describe('resolveWorkspacePath', () => { + it('should resolve relative paths', () => { + const result = resolveWorkspacePath('/workspace', 'src/skills') + expect(result).toBe(path.resolve('/workspace', 'src/skills')) + }) + + it('should expand home directory in workspace path', () => { + const result = resolveWorkspacePath('~/project', 'src') + expect(result).toBe(path.resolve(os.homedir(), 'project', 'src')) + }) + + it('should use cache on second call', () => { + const workspaceDir = '/workspace' + const relativePath = 'src/skills' + + const result1 = resolveWorkspacePath(workspaceDir, relativePath) + const result2 = resolveWorkspacePath(workspaceDir, relativePath) + + expect(result1).toBe(result2) + }) + + it('should skip cache when useCache is false', () => { + const workspaceDir = '/workspace' + const relativePath = 'src/skills' + + const result1 = resolveWorkspacePath(workspaceDir, relativePath, false) + const result2 = resolveWorkspacePath(workspaceDir, relativePath, false) + + expect(result1).toBe(result2) + }) // Both should be computed (not from cache) + }) + + describe('getAbsoluteSrcPath', () => { + it('should return absolute source path', () => { + const config: TnmscConfig = { + version: '2026.10218.12101', + workspaceDir: '/workspace', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test', + username: 'test', + gender: 'male', + birthday: '1990-01-01' + } + } + + const result = getAbsoluteSrcPath(config, config.aindex.skills) + expect(result).toBe(path.resolve('/workspace', 'skills')) + }) + }) + + describe('getAbsoluteDistPath', () => { + it('should return absolute distribution path', () => { + const config: TnmscConfig = { + version: '2026.10218.12101', + workspaceDir: '/workspace', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test', + username: 'test', + gender: 'male', + birthday: '1990-01-01' + } + } + + const result = getAbsoluteDistPath(config, config.aindex.skills) + expect(result).toBe(path.resolve('/workspace', 'dist/skills')) + }) + }) + + describe('resolveModulePaths', () => { + it('should return resolved paths for module', () => { + const config: TnmscConfig = { + version: '2026.10218.12101', + workspaceDir: '/workspace', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test', + username: 'test', + gender: 'male', + birthday: '1990-01-01' + } + } + + const result = resolveModulePaths(config, config.aindex.skills) + + expect(result.absoluteSrc).toBe(path.resolve('/workspace', 'skills')) + expect(result.absoluteDist).toBe(path.resolve('/workspace', 'dist/skills')) + expect(result.relativeSrc).toBe('skills') + expect(result.relativeDist).toBe('dist/skills') + }) + }) + + describe('getAbsoluteWorkspaceDir', () => { + it('should expand home directory', () => { + const result = getAbsoluteWorkspaceDir('~/project') + expect(result).toBe(path.join(os.homedir(), 'project')) + }) + + it('should return absolute path as-is', () => { + const result = getAbsoluteWorkspaceDir('/workspace') + expect(result).toBe('/workspace') + }) + }) + + describe('getRelativePath', () => { + it('should return relative path from workspace', () => { + const result = getRelativePath('/workspace', '/workspace/src/skills') + expect(result).toBe(path.normalize('src/skills')) + }) + + it('should expand home directory in workspace', () => { + const result = getRelativePath('~/project', path.join(os.homedir(), 'project', 'src')) + expect(result).toBe('src') + }) + }) + + describe('isAbsolutePath', () => { + it('should return true for absolute paths', () => expect(isAbsolutePath('/absolute/path')).toBe(true)) + + it('should return false for relative paths', () => expect(isAbsolutePath('relative/path')).toBe(false)) + + it('should return false for paths starting with ~', () => expect(isAbsolutePath('~/path')).toBe(false)) + }) + + describe('normalizePath', () => { + it('should normalize path separators', () => { + const result = normalizePath('path//to///file') + expect(result).toBe(path.normalize('path//to///file')) + }) + + it('should resolve . and ..', () => { + const result = normalizePath('/path/to/../file') + expect(result).toBe(path.normalize('/path/to/../file')) + }) + }) + + describe('joinPath', () => { + it('should join path segments', () => { + const result = joinPath('path', 'to', 'file') + expect(result).toBe(path.join('path', 'to', 'file')) + }) + }) + + describe('resolveAllAindexPaths', () => { + it('should resolve all aindex module paths', () => { + const config: TnmscConfig = { + version: '2026.10218.12101', + workspaceDir: '/workspace', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test', + username: 'test', + gender: 'male', + birthday: '1990-01-01' + } + } + + const result = resolveAllAindexPaths(config) + + expect(result.skills.absoluteSrc).toBe(path.resolve('/workspace', 'skills')) + expect(result.commands.absoluteSrc).toBe(path.resolve('/workspace', 'commands')) + expect(result.subAgents.absoluteSrc).toBe(path.resolve('/workspace', 'agents')) + expect(result.rules.absoluteSrc).toBe(path.resolve('/workspace', 'rules')) + expect(result.globalPrompt.absoluteSrc).toBe(path.resolve('/workspace', 'app/global.cn.mdx')) + expect(result.workspacePrompt.absoluteSrc).toBe(path.resolve('/workspace', 'app/workspace.cn.mdx')) + expect(result.app.absoluteSrc).toBe(path.resolve('/workspace', 'app')) + expect(result.ext.absoluteSrc).toBe(path.resolve('/workspace', 'ext')) + expect(result.arch.absoluteSrc).toBe(path.resolve('/workspace', 'arch')) + }) + }) + + describe('getAindexModulePaths', () => { + it('should return resolved paths for valid module', () => { + const config: TnmscConfig = { + version: '2026.10218.12101', + workspaceDir: '/workspace', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test', + username: 'test', + gender: 'male', + birthday: '1990-01-01' + } + } + + const result = getAindexModulePaths(config, 'skills') + + expect(result.absoluteSrc).toBe(path.resolve('/workspace', 'skills')) + expect(result.absoluteDist).toBe(path.resolve('/workspace', 'dist/skills')) + }) + + it('should throw for invalid module name', () => { + const config: TnmscConfig = { + version: '2026.10218.12101', + workspaceDir: '/workspace', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: 'Test', + username: 'test', + gender: 'male', + birthday: '1990-01-01' + } + } + + expect(() => getAindexModulePaths(config, 'invalidModule' as keyof TnmscConfig['aindex'] & string)) // Type assertion to test invalid module name + .toThrow('Invalid aindex module') + }) + }) + + describe('clearPathCache', () => { + it('should clear the path cache', () => { + resolveWorkspacePath('/workspace', 'src/skills') // Populate cache + + clearPathCache() // Clear cache + + const result = resolveWorkspacePath('/workspace', 'src/skills') // Should not throw and should recompute path + expect(result).toBe(path.resolve('/workspace', 'src/skills')) + }) + }) +}) diff --git a/cli/src/config/pathResolver.ts b/cli/src/config/pathResolver.ts new file mode 100644 index 00000000..8fe443ef --- /dev/null +++ b/cli/src/config/pathResolver.ts @@ -0,0 +1,232 @@ +/** + * Path resolution utilities for the TNMSC configuration system. + * + * This module provides functions for resolving paths relative to the + * workspace directory, expanding home directory shortcuts, and caching + * resolved paths for performance. + */ + +import type {ModulePaths, ResolvedModulePaths, TnmscConfig} from './types' +import * as os from 'node:os' +import * as path from 'node:path' +import {ConfigPathError} from './errors' + +/** + * Cache for resolved paths to avoid redundant computations. + */ +const pathCache = new Map() + +/** + * Clear the path cache. + * This should be called when the configuration is reloaded. + */ +export function clearPathCache(): void { + pathCache.clear() +} + +/** + * Get the cache key for a path resolution. + */ +function getCacheKey(workspaceDir: string, relativePath: string): string { + return `${workspaceDir}::${relativePath}` +} + +/** + * Expand the tilde (~) in a path to the user's home directory. + * + * @param inputPath - The path that may contain a tilde + * @returns The path with tilde expanded to the home directory + */ +export function expandHomeDir(inputPath: string): string { + if (!inputPath.startsWith('~')) return inputPath + + const homeDir = os.homedir() + + if (inputPath === '~') return homeDir + + if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) return path.join(homeDir, inputPath.slice(2)) + + return inputPath // Handle ~username syntax (not supported, return as-is) +} + +/** + * Resolve a path relative to the workspace directory. + * + * @param workspaceDir - The workspace directory (may contain ~) + * @param relativePath - The path relative to the workspace + * @param useCache - Whether to use the path cache + * @returns The absolute resolved path + * @throws {ConfigPathError} If path resolution fails + */ +export function resolveWorkspacePath( + workspaceDir: string, + relativePath: string, + useCache = true +): string { + const cacheKey = getCacheKey(workspaceDir, relativePath) + + if (useCache && pathCache.has(cacheKey)) return pathCache.get(cacheKey)! + + try { + const expandedWorkspace = expandHomeDir(workspaceDir) + const resolvedPath = path.resolve(expandedWorkspace, relativePath) + + if (useCache) pathCache.set(cacheKey, resolvedPath) + + return resolvedPath + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw new ConfigPathError(workspaceDir, relativePath, reason) + } +} + +/** + * Get the absolute path for a module's source directory. + * + * @param config - The TNMSC configuration + * @param modulePath - The module paths (src/dist pair) + * @returns The absolute source path + */ +export function getAbsoluteSrcPath(config: TnmscConfig, modulePath: ModulePaths): string { + return resolveWorkspacePath(config.workspaceDir, modulePath.src) +} + +/** + * Get the absolute path for a module's distribution directory. + * + * @param config - The TNMSC configuration + * @param modulePath - The module paths (src/dist pair) + * @returns The absolute distribution path + */ +export function getAbsoluteDistPath(config: TnmscConfig, modulePath: ModulePaths): string { + return resolveWorkspacePath(config.workspaceDir, modulePath.dist) +} + +/** + * Get both absolute and relative paths for a module. + * + * @param config - The TNMSC configuration + * @param modulePath - The module paths (src/dist pair) + * @returns Resolved paths with both absolute and relative variants + */ +export function resolveModulePaths( + config: TnmscConfig, + modulePath: ModulePaths +): ResolvedModulePaths { + return { + absoluteSrc: getAbsoluteSrcPath(config, modulePath), + absoluteDist: getAbsoluteDistPath(config, modulePath), + relativeSrc: modulePath.src, + relativeDist: modulePath.dist + } +} + +/** + * Get the absolute workspace directory path. + * + * @param workspaceDir - The workspace directory (may contain ~) + * @returns The absolute workspace directory path + */ +export function getAbsoluteWorkspaceDir(workspaceDir: string): string { + return expandHomeDir(workspaceDir) +} + +/** + * Get the relative path from the workspace directory. + * + * @param workspaceDir - The workspace directory (may contain ~) + * @param absolutePath - The absolute path to make relative + * @returns The relative path from workspace + */ +export function getRelativePath(workspaceDir: string, absolutePath: string): string { + const expandedWorkspace = expandHomeDir(workspaceDir) + return path.relative(expandedWorkspace, absolutePath) +} + +/** + * Check if a path is absolute. + * + * @param inputPath - The path to check + * @returns True if the path is absolute + */ +export function isAbsolutePath(inputPath: string): boolean { + return path.isAbsolute(inputPath) +} + +/** + * Normalize a path for the current platform. + * + * @param inputPath - The path to normalize + * @returns The normalized path + */ +export function normalizePath(inputPath: string): string { + return path.normalize(inputPath) +} + +/** + * Join multiple path segments. + * + * @param segments - The path segments to join + * @returns The joined path + */ +export function joinPath(...segments: string[]): string { + return path.join(...segments) +} + +/** + * Get all resolved paths for the aindex configuration. + * + * @param config - The TNMSC configuration + * @returns Object with all module paths resolved + */ +export function resolveAllAindexPaths(config: TnmscConfig): { + skills: ResolvedModulePaths + commands: ResolvedModulePaths + subAgents: ResolvedModulePaths + rules: ResolvedModulePaths + globalPrompt: ResolvedModulePaths + workspacePrompt: ResolvedModulePaths + app: ResolvedModulePaths + ext: ResolvedModulePaths + arch: ResolvedModulePaths +} { + const {aindex} = config + + return { + skills: resolveModulePaths(config, aindex.skills), + commands: resolveModulePaths(config, aindex.commands), + subAgents: resolveModulePaths(config, aindex.subAgents), + rules: resolveModulePaths(config, aindex.rules), + globalPrompt: resolveModulePaths(config, aindex.globalPrompt), + workspacePrompt: resolveModulePaths(config, aindex.workspacePrompt), + app: resolveModulePaths(config, aindex.app), + ext: resolveModulePaths(config, aindex.ext), + arch: resolveModulePaths(config, aindex.arch) + } +} + +/** + * Get a specific aindex module's resolved paths. + * + * @param config - The TNMSC configuration + * @param moduleName - The name of the module + * @returns The resolved module paths + * @throws {ConfigPathError} If the module name is invalid + */ +export function getAindexModulePaths( + config: TnmscConfig, + moduleName: keyof TnmscConfig['aindex'] & string +): ResolvedModulePaths { + const modulePaths = config.aindex[moduleName] + + if (modulePaths === void 0 || modulePaths === null || typeof modulePaths !== 'object' || !('src' in modulePaths)) { + throw new ConfigPathError( + config.workspaceDir, + moduleName, + `Invalid aindex module: ${moduleName}` + ) + } + + return resolveModulePaths(config, modulePaths) +} diff --git a/cli/src/config/schema.test.ts b/cli/src/config/schema.test.ts new file mode 100644 index 00000000..e9899a05 --- /dev/null +++ b/cli/src/config/schema.test.ts @@ -0,0 +1,293 @@ +/** + * Unit tests for schema validation + */ + +import type {TnmscConfig} from './types' +import {describe, expect, it} from 'vitest' +import { + formatValidationErrors, + getDefaultConfig, + isValidLogLevel, + safeValidateConfig, + validateConfig, + ZAindexConfig, + ZModulePaths, + ZProfile, + ZTnmscConfig +} from './schema' + +describe('schema validation', () => { + const validConfig: TnmscConfig = { + version: '2026.10218.12101', + workspaceDir: '~/project', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + logLevel: 'info', + profile: { + name: '赵日天', + username: 'TrueNine', + gender: 'male', + birthday: '1997-11-04' + } + } + + describe('zModulePaths', () => { + it('should validate valid module paths', () => { + const result = ZModulePaths.safeParse({src: 'skills', dist: 'dist/skills'}) + expect(result.success).toBe(true) + }) + + it('should reject empty src path', () => { + const result = ZModulePaths.safeParse({src: '', dist: 'dist/skills'}) + expect(result.success).toBe(false) + }) + + it('should reject empty dist path', () => { + const result = ZModulePaths.safeParse({src: 'skills', dist: ''}) + expect(result.success).toBe(false) + }) + + it('should reject missing src', () => { + const result = ZModulePaths.safeParse({dist: 'dist/skills'}) + expect(result.success).toBe(false) + }) + + it('should reject missing dist', () => { + const result = ZModulePaths.safeParse({src: 'skills'}) + expect(result.success).toBe(false) + }) + }) + + describe('zAindexConfig', () => { + it('should validate valid aindex config', () => { + const result = ZAindexConfig.safeParse(validConfig.aindex) + expect(result.success).toBe(true) + }) + + it('should reject empty name', () => { + const invalidConfig = { + ...validConfig.aindex, + name: '' + } + const result = ZAindexConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should reject missing skills', () => { + const {skills: _, ...invalidConfig} = validConfig.aindex + const result = ZAindexConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should reject invalid skills paths', () => { + const invalidConfig = { + ...validConfig.aindex, + skills: {src: '', dist: 'dist/skills'} + } + const result = ZAindexConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + }) + + describe('zProfile', () => { + it('should validate valid profile', () => { + const result = ZProfile.safeParse(validConfig.profile) + expect(result.success).toBe(true) + }) + + it('should reject empty name', () => { + const invalidProfile = {...validConfig.profile, name: ''} + const result = ZProfile.safeParse(invalidProfile) + expect(result.success).toBe(false) + }) + + it('should reject empty username', () => { + const invalidProfile = {...validConfig.profile, username: ''} + const result = ZProfile.safeParse(invalidProfile) + expect(result.success).toBe(false) + }) + + it('should reject empty gender', () => { + const invalidProfile = {...validConfig.profile, gender: ''} + const result = ZProfile.safeParse(invalidProfile) + expect(result.success).toBe(false) + }) + + it('should reject invalid birthday format', () => { + const invalidProfile = {...validConfig.profile, birthday: '1997/11/04'} + const result = ZProfile.safeParse(invalidProfile) + expect(result.success).toBe(false) + }) + + it('should reject birthday without leading zeros', () => { + const invalidProfile = {...validConfig.profile, birthday: '1997-1-4'} + const result = ZProfile.safeParse(invalidProfile) + expect(result.success).toBe(false) + }) + }) + + describe('zTnmscConfig', () => { + it('should validate valid configuration', () => { + const result = ZTnmscConfig.safeParse(validConfig) + expect(result.success).toBe(true) + }) + + it('should reject missing version', () => { + const {version: _, ...invalidConfig} = validConfig + const result = ZTnmscConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should reject missing workspaceDir', () => { + const {workspaceDir: _, ...invalidConfig} = validConfig + const result = ZTnmscConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should reject missing aindex', () => { + const {aindex: _, ...invalidConfig} = validConfig + const result = ZTnmscConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should reject missing logLevel', () => { + const {logLevel: _, ...invalidConfig} = validConfig + const result = ZTnmscConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should reject missing profile', () => { + const {profile: _, ...invalidConfig} = validConfig + const result = ZTnmscConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should reject invalid version format', () => { + const invalidConfig = {...validConfig, version: '1.0.0'} + const result = ZTnmscConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should reject version without dots', () => { + const invalidConfig = {...validConfig, version: '20261021812101'} + const result = ZTnmscConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should reject empty workspaceDir', () => { + const invalidConfig = {...validConfig, workspaceDir: ''} + const result = ZTnmscConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should reject invalid logLevel', () => { + const invalidConfig = {...validConfig, logLevel: 'verbose'} + const result = ZTnmscConfig.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + it('should accept all valid log levels', () => { + const validLevels = ['trace', 'debug', 'info', 'warn', 'error'] as const + for (const level of validLevels) { + const testConfig = {...validConfig, logLevel: level} + const result = ZTnmscConfig.safeParse(testConfig) + expect(result.success).toBe(true) + } + }) + }) + + describe('validateConfig', () => { + it('should return validated config for valid input', () => { + const result = validateConfig(validConfig) + expect(result.version).toBe('2026.10218.12101') + expect(result.workspaceDir).toBe('~/project') + }) + + it('should throw for invalid config', () => { + expect(() => validateConfig({})).toThrow() + }) + }) + + describe('safeValidateConfig', () => { + it('should return success for valid config', () => { + const result = safeValidateConfig(validConfig) + expect(result.success).toBe(true) + if (result.success) expect(result.data.version).toBe('2026.10218.12101') + }) + + it('should return failure for invalid config', () => { + const result = safeValidateConfig({}) + expect(result.success).toBe(false) + if (!result.success) expect(result.error).toBeDefined() + }) + }) + + describe('formatValidationErrors', () => { + it('should format validation errors', () => { + const result = safeValidateConfig({}) + expect(result.success).toBe(false) + + if (result.success) return + + const formatted = formatValidationErrors(result.error) + expect(formatted.length).toBeGreaterThan(0) + expect(formatted[0]).toContain(':') + }) + }) + + describe('isValidLogLevel', () => { + it('should return true for valid log levels', () => { + expect(isValidLogLevel('trace')).toBe(true) + expect(isValidLogLevel('debug')).toBe(true) + expect(isValidLogLevel('info')).toBe(true) + expect(isValidLogLevel('warn')).toBe(true) + expect(isValidLogLevel('error')).toBe(true) + }) + + it('should return false for invalid log levels', () => { + expect(isValidLogLevel('verbose')).toBe(false) + expect(isValidLogLevel('warning')).toBe(false) + expect(isValidLogLevel('')).toBe(false) + expect(isValidLogLevel(null)).toBe(false) + expect(isValidLogLevel(void 0)).toBe(false) + expect(isValidLogLevel(123)).toBe(false) + }) + }) + + describe('getDefaultConfig', () => { + it('should return default configuration', () => { + const defaults = getDefaultConfig() + expect(defaults.version).toBe('2026.00000.00000') + expect(defaults.workspaceDir).toBe('~/project') + expect(defaults.logLevel).toBe('info') + expect(defaults.aindex).toBeDefined() + expect(defaults.aindex?.name).toBe('aindex') + expect(defaults.profile).toBeDefined() + }) + + it('should have all aindex modules in default config', () => { + const defaults = getDefaultConfig() + const aindex = defaults.aindex! + + expect(aindex.skills).toEqual({src: 'skills', dist: 'dist/skills'}) + expect(aindex.commands).toEqual({src: 'commands', dist: 'dist/commands'}) + expect(aindex.subAgents).toEqual({src: 'agents', dist: 'dist/agents'}) + expect(aindex.rules).toEqual({src: 'rules', dist: 'dist/rules'}) + expect(aindex.globalPrompt).toEqual({src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}) + expect(aindex.workspacePrompt).toEqual({src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}) + expect(aindex.app).toEqual({src: 'app', dist: 'dist/app'}) + expect(aindex.ext).toEqual({src: 'ext', dist: 'dist/ext'}) + expect(aindex.arch).toEqual({src: 'arch', dist: 'dist/arch'}) + }) + }) +}) diff --git a/cli/src/config/schema.ts b/cli/src/config/schema.ts new file mode 100644 index 00000000..985cfb89 --- /dev/null +++ b/cli/src/config/schema.ts @@ -0,0 +1,158 @@ +/** + * Zod validation schemas for the TNMSC configuration system. + * + * This module provides runtime validation for configuration files, + * ensuring all required fields exist and have valid formats. + */ + +import type { + AindexConfig, + LogLevel, + ModulePaths, + Profile, + TnmscConfig +} from './types' +import {z} from 'zod/v3' + +const VERSION_REGEX = /^\d{4}\.\d{5}\.\d{5}$/ + +const BIRTHDAY_REGEX = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Valid log level values. + */ +const VALID_LOG_LEVELS: Set = new Set(['trace', 'debug', 'info', 'warn', 'error']) + +/** + * Zod schema for module path pairs (src/dist). + */ +export const ZModulePaths = z.object({ + src: z.string().min(1, 'Source path cannot be empty'), + dist: z.string().min(1, 'Distribution path cannot be empty') +}) satisfies z.ZodType + +/** + * Zod schema for aindex configuration. + */ +export const ZAindexConfig = z.object({ + name: z.string().min(1, 'Aindex name cannot be empty'), + skills: ZModulePaths, + commands: ZModulePaths, + subAgents: ZModulePaths, + rules: ZModulePaths, + globalPrompt: ZModulePaths, + workspacePrompt: ZModulePaths, + app: ZModulePaths, + ext: ZModulePaths, + arch: ZModulePaths +}) satisfies z.ZodType + +/** + * Zod schema for user profile. + */ +export const ZProfile = z.object({ + name: z.string().min(1, 'Profile name cannot be empty'), + username: z.string().min(1, 'Username cannot be empty'), + gender: z.string().min(1, 'Gender cannot be empty'), + birthday: z.string() + .regex(BIRTHDAY_REGEX, 'Birthday must be in YYYY-MM-DD format') +}) satisfies z.ZodType + +/** + * Zod schema for the main TNMSC configuration. + */ +export const ZTnmscConfig = z.object({ + version: z.string() + .regex(VERSION_REGEX, 'Version must be in YYYY.MMDD.HHMM format'), + workspaceDir: z.string().min(1, 'Workspace directory cannot be empty'), + aindex: ZAindexConfig, + logLevel: z.enum(['trace', 'debug', 'info', 'warn', 'error']), + profile: ZProfile +}) satisfies z.ZodType + +/** + * Validate a configuration object against the schema. + * + * @param config - The configuration object to validate + * @returns The validated configuration + * @throws {z.ZodError} If validation fails + */ +export function validateConfig(config: unknown): TnmscConfig { + return ZTnmscConfig.parse(config) +} + +/** + * Safely validate a configuration object against the schema. + * + * @param config - The configuration object to validate + * @returns An object with success flag and either data or error + */ +export function safeValidateConfig(config: unknown): + | {success: true, data: TnmscConfig} + | {success: false, error: z.ZodError} { + const result = ZTnmscConfig.safeParse(config) + if (result.success) return {success: true, data: result.data} + return {success: false, error: result.error} +} + +/** + * Format validation errors into human-readable messages. + * + * @param error - The Zod error to format + * @returns Array of error message strings + */ +export function formatValidationErrors(error: z.ZodError): string[] { + return error.issues.map(issue => { + const path = issue.path.length > 0 ? issue.path.join('.') : 'root' + return `${path}: ${issue.message}` + }) +} + +/** + * Check if a value is a valid log level. + * + * @param value - The value to check + * @returns True if the value is a valid log level + */ +export function isValidLogLevel(value: unknown): value is LogLevel { + return typeof value === 'string' && VALID_LOG_LEVELS.has(value as LogLevel) +} + +/** + * Get the default configuration values. + * + * @returns A partial configuration with default values + */ +export function getDefaultConfig(): Partial { + return { + version: '2026.00000.00000', + workspaceDir: '~/project', + logLevel: 'info', + aindex: { + name: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'agents', dist: 'dist/agents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'app/workspace.cn.mdx', dist: 'dist/workspace.mdx'}, + app: {src: 'app', dist: 'dist/app'}, + ext: {src: 'ext', dist: 'dist/ext'}, + arch: {src: 'arch', dist: 'dist/arch'} + }, + profile: { + name: '', + username: '', + gender: '', + birthday: '' + } + } +} + +export { // Re-export types for convenience + type AindexConfig, + type LogLevel, + type ModulePaths, + type Profile, + type TnmscConfig +} from './types' diff --git a/cli/src/config/types.ts b/cli/src/config/types.ts new file mode 100644 index 00000000..fdafd828 --- /dev/null +++ b/cli/src/config/types.ts @@ -0,0 +1,114 @@ +/** + * Configuration types for the TNMSC configuration system. + * + * This module defines TypeScript interfaces that match the exact JSON + * configuration structure located at ~/.aindex/.tnmsc.json + */ + +/** + * Module path pair containing source and distribution paths. + * Both paths are relative to the workspace directory. + */ +export interface ModulePaths { + /** Source path (human-authored files) */ + readonly src: string + /** Output/compiled path (read by the system) */ + readonly dist: string +} + +/** + * Aindex configuration containing all module paths. + * This replaces the previous shadowSourceProject configuration. + */ +export interface AindexConfig { + /** Name of the aindex configuration */ + readonly name: string + /** Skills module paths */ + readonly skills: ModulePaths + /** Commands module paths */ + readonly commands: ModulePaths + /** Sub-agents module paths */ + readonly subAgents: ModulePaths + /** Rules module paths */ + readonly rules: ModulePaths + /** Global prompt file paths */ + readonly globalPrompt: ModulePaths + /** Workspace prompt file paths */ + readonly workspacePrompt: ModulePaths + /** Application module paths */ + readonly app: ModulePaths + /** Extension module paths */ + readonly ext: ModulePaths + /** Architecture module paths */ + readonly arch: ModulePaths +} + +/** + * User profile information. + */ +export interface Profile { + /** Display name of the user */ + readonly name: string + /** Username/login identifier */ + readonly username: string + /** Gender of the user */ + readonly gender: string + readonly birthday: string +} + +/** + * Log level options for the application. + */ +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' + +/** + * Main TNMSC configuration interface. + * This matches the structure of ~/.aindex/.tnmsc.json + */ +export interface TnmscConfig { + readonly version: string + /** Workspace directory path (supports ~ for home directory) */ + readonly workspaceDir: string + /** Aindex module configuration */ + readonly aindex: AindexConfig + /** Log level setting */ + readonly logLevel: LogLevel + /** User profile information */ + readonly profile: Profile +} + +/** + * Configuration load result containing the config and metadata. + */ +export interface ConfigLoadResult { + /** The loaded configuration */ + readonly config: TnmscConfig + /** Path to the configuration file */ + readonly source: string + /** Whether the configuration was found and loaded */ + readonly found: boolean +} + +/** + * Configuration service options. + */ +export interface ConfigServiceOptions { + /** Custom path to the configuration file */ + readonly configPath?: string + /** Whether to cache the configuration after loading */ + readonly enableCache?: boolean +} + +/** + * Resolved paths for an aindex module. + */ +export interface ResolvedModulePaths { + /** Absolute source path */ + readonly absoluteSrc: string + /** Absolute distribution path */ + readonly absoluteDist: string + /** Source path relative to workspace */ + readonly relativeSrc: string + /** Distribution path relative to workspace */ + readonly relativeDist: string +} From c19bd72679d1888605575d5971dc282e6e6168bf Mon Sep 17 00:00:00 2001 From: TrueNine Date: Sun, 1 Mar 2026 11:44:08 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E6=8F=92=E4=BB=B6=E5=92=8C=E5=B7=A5=E5=85=B7=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增多个输入输出插件,包括Git忽略、编辑器配置、IDE配置等 - 添加插件共享类型和工具函数 - 实现文件路径处理和系列过滤功能 - 增加测试用例和属性测试 - 更新构建脚本以生成JSON schema - 优化项目配置和依赖管理 --- cli/eslint.config.ts | 2 +- cli/package.json | 45 +- cli/scripts/generate-schema.ts | 5 + .../plugins/desk-paths/index.property.test.ts | 174 ++++ cli/src/plugins/desk-paths/index.ts | 401 +++++++++ .../GenericSkillsOutputPlugin.test.ts | 447 ++++++++++ .../GenericSkillsOutputPlugin.ts | 468 ++++++++++ .../plugin-agentskills-compact/index.ts | 3 + .../plugin-agentsmd/AgentsOutputPlugin.ts | 74 ++ cli/src/plugins/plugin-agentsmd/index.ts | 3 + .../AntigravityOutputPlugin.test.ts | 343 ++++++++ .../AntigravityOutputPlugin.ts | 216 +++++ cli/src/plugins/plugin-antigravity/index.ts | 3 + ...eCodeCLIOutputPlugin.projectConfig.test.ts | 214 +++++ ...ClaudeCodeCLIOutputPlugin.property.test.ts | 161 ++++ .../ClaudeCodeCLIOutputPlugin.test.ts | 504 +++++++++++ .../ClaudeCodeCLIOutputPlugin.ts | 135 +++ .../plugins/plugin-claude-code-cli/index.ts | 3 + .../CursorOutputPlugin.projectConfig.test.ts | 214 +++++ .../plugin-cursor/CursorOutputPlugin.test.ts | 833 ++++++++++++++++++ .../plugin-cursor/CursorOutputPlugin.ts | 534 +++++++++++ cli/src/plugins/plugin-cursor/index.ts | 3 + .../DroidCLIOutputPlugin.test.ts | 269 ++++++ .../plugin-droid-cli/DroidCLIOutputPlugin.ts | 58 ++ cli/src/plugins/plugin-droid-cli/index.ts | 3 + .../EditorConfigOutputPlugin.ts | 79 ++ cli/src/plugins/plugin-editorconfig/index.ts | 3 + .../GeminiCLIOutputPlugin.ts | 16 + cli/src/plugins/plugin-gemini-cli/index.ts | 3 + .../GitExcludeOutputPlugin.test.ts | 265 ++++++ .../GitExcludeOutputPlugin.ts | 275 ++++++ cli/src/plugins/plugin-git-exclude/index.ts | 3 + .../SkillInputPlugin.test.ts | 309 +++++++ .../SkillInputPlugin.ts | 476 ++++++++++ .../plugins/plugin-input-agentskills/index.ts | 3 + .../EditorConfigInputPlugin.ts | 44 + .../plugin-input-editorconfig/index.ts | 3 + .../FastCommandInputPlugin.test.ts | 131 +++ .../FastCommandInputPlugin.ts | 200 +++++ .../plugin-input-fast-command/index.ts | 6 + .../GitExcludeInputPlugin.test.ts | 78 ++ .../GitExcludeInputPlugin.ts | 23 + .../plugins/plugin-input-git-exclude/index.ts | 3 + .../GitIgnoreInputPlugin.test.ts | 66 ++ .../GitIgnoreInputPlugin.ts | 30 + .../plugins/plugin-input-gitignore/index.ts | 3 + .../GlobalMemoryInputPlugin.ts | 87 ++ .../plugin-input-global-memory/index.ts | 3 + .../JetBrainsConfigInputPlugin.ts | 52 ++ .../plugin-input-jetbrains-config/index.ts | 3 + ...eCleanupEffectInputPlugin.property.test.ts | 311 +++++++ ...kdownWhitespaceCleanupEffectInputPlugin.ts | 153 ++++ .../plugin-input-md-cleanup-effect/index.ts | 6 + ...eCleanupEffectInputPlugin.property.test.ts | 263 ++++++ .../OrphanFileCleanupEffectInputPlugin.ts | 214 +++++ .../index.ts | 6 + .../ProjectPromptInputPlugin.test.ts | 214 +++++ .../ProjectPromptInputPlugin.ts | 235 +++++ .../plugin-input-project-prompt/index.ts | 3 + .../ReadmeMdInputPlugin.property.test.ts | 365 ++++++++ .../ReadmeMdInputPlugin.ts | 155 ++++ cli/src/plugins/plugin-input-readme/index.ts | 3 + .../plugin-input-rule/RuleInputPlugin.test.ts | 322 +++++++ .../plugin-input-rule/RuleInputPlugin.ts | 176 ++++ cli/src/plugins/plugin-input-rule/index.ts | 3 + .../ShadowProjectInputPlugin.test.ts | 164 ++++ .../ShadowProjectInputPlugin.ts | 118 +++ .../plugin-input-shadow-project/index.ts | 3 + .../AIAgentIgnoreInputPlugin.ts | 47 + .../plugin-input-shared-ignore/index.ts | 3 + .../AbstractInputPlugin.test.ts | 357 ++++++++ .../AbstractInputPlugin.ts | 147 ++++ .../BaseDirectoryInputPlugin.ts | 144 +++ .../BaseFileInputPlugin.ts | 57 ++ cli/src/plugins/plugin-input-shared/index.ts | 15 + .../scope/GlobalScopeCollector.ts | 117 +++ .../scope/ScopeRegistry.ts | 114 +++ .../plugin-input-shared/scope/index.ts | 14 + ...FileSyncEffectInputPlugin.property.test.ts | 261 ++++++ .../SkillNonSrcFileSyncEffectInputPlugin.ts | 182 ++++ .../plugin-input-skill-sync-effect/index.ts | 6 + .../SubAgentInputPlugin.test.ts | 137 +++ .../SubAgentInputPlugin.ts | 200 +++++ .../plugins/plugin-input-subagent/index.ts | 6 + .../VSCodeConfigInputPlugin.ts | 48 + .../plugin-input-vscode-config/index.ts | 3 + .../WorkspaceInputPlugin.ts | 31 + .../plugins/plugin-input-workspace/index.ts | 3 + ...BrainsAIAssistantCodexOutputPlugin.test.ts | 391 ++++++++ .../JetBrainsAIAssistantCodexOutputPlugin.ts | 607 +++++++++++++ .../plugin-jetbrains-ai-codex/index.ts | 3 + ...JetBrainsIDECodeStyleConfigOutputPlugin.ts | 144 +++ .../plugin-jetbrains-codestyle/index.ts | 3 + .../CodexCLIOutputPlugin.ts | 189 ++++ .../plugins/plugin-openai-codex-cli/index.ts | 3 + ...ncodeCLIOutputPlugin.projectConfig.test.ts | 231 +++++ .../OpencodeCLIOutputPlugin.property.test.ts | 158 ++++ .../OpencodeCLIOutputPlugin.test.ts | 777 ++++++++++++++++ .../OpencodeCLIOutputPlugin.ts | 467 ++++++++++ cli/src/plugins/plugin-opencode-cli/index.ts | 3 + .../AbstractOutputPlugin.test.ts | 717 +++++++++++++++ .../AbstractOutputPlugin.ts | 543 ++++++++++++ .../BaseCLIOutputPlugin.ts | 405 +++++++++ cli/src/plugins/plugin-output-shared/index.ts | 14 + .../registry/RegistryWriter.ts | 149 ++++ .../plugin-output-shared/registry/index.ts | 3 + .../utils/commandFilter.ts | 11 + .../plugin-output-shared/utils/gitUtils.ts | 121 +++ .../plugin-output-shared/utils/index.ts | 25 + .../utils/pathNormalization.property.test.ts | 57 ++ .../plugin-output-shared/utils/ruleFilter.ts | 105 +++ ...esFilter.napi-equivalence.property.test.ts | 107 +++ .../utils/seriesFilter.property.test.ts | 154 ++++ .../utils/seriesFilter.ts | 61 ++ .../plugin-output-shared/utils/skillFilter.ts | 11 + .../utils/subAgentFilter.ts | 11 + .../subSeriesGlobExpansion.property.test.ts | 196 +++++ .../typeSpecificFilters.property.test.ts | 119 +++ ...rIDEPluginOutputPlugin.frontmatter.test.ts | 63 ++ ...DEPluginOutputPlugin.projectConfig.test.ts | 118 +++ .../QoderIDEPluginOutputPlugin.test.ts | 485 ++++++++++ .../QoderIDEPluginOutputPlugin.ts | 426 +++++++++ cli/src/plugins/plugin-qoder-ide/index.ts | 3 + ...eMdConfigFileOutputPlugin.property.test.ts | 499 +++++++++++ .../ReadmeMdConfigFileOutputPlugin.ts | 128 +++ cli/src/plugins/plugin-readme/index.ts | 3 + .../plugins/plugin-shared/AbstractPlugin.ts | 26 + cli/src/plugins/plugin-shared/PluginNames.ts | 24 + cli/src/plugins/plugin-shared/constants.ts | 11 + cli/src/plugins/plugin-shared/index.ts | 23 + cli/src/plugins/plugin-shared/log.ts | 9 + .../plugins/plugin-shared/testing/index.ts | 65 ++ .../types/ConfigTypes.schema.property.test.ts | 92 ++ .../plugin-shared/types/ConfigTypes.schema.ts | 122 +++ cli/src/plugins/plugin-shared/types/Enums.ts | 75 ++ cli/src/plugins/plugin-shared/types/Errors.ts | 40 + .../types/ExportMetadataTypes.ts | 213 +++++ .../plugin-shared/types/FileSystemTypes.ts | 37 + .../plugins/plugin-shared/types/InputTypes.ts | 417 +++++++++ .../plugin-shared/types/OutputTypes.ts | 24 + .../plugin-shared/types/PluginTypes.ts | 390 ++++++++ .../plugin-shared/types/PromptTypes.ts | 146 +++ .../plugin-shared/types/RegistryTypes.ts | 106 +++ .../types/ShadowSourceProjectTypes.ts | 298 +++++++ cli/src/plugins/plugin-shared/types/index.ts | 11 + .../seriNamePropagation.property.test.ts | 82 ++ .../TraeIDEOutputPlugin.test.ts | 135 +++ .../plugin-trae-ide/TraeIDEOutputPlugin.ts | 167 ++++ cli/src/plugins/plugin-trae-ide/index.ts | 3 + .../VisualStudioCodeIDEConfigOutputPlugin.ts | 134 +++ cli/src/plugins/plugin-vscode/index.ts | 3 + .../WarpIDEOutputPlugin.test.ts | 513 +++++++++++ .../plugin-warp-ide/WarpIDEOutputPlugin.ts | 128 +++ cli/src/plugins/plugin-warp-ide/index.ts | 3 + ...WindsurfOutputPlugin.projectConfig.test.ts | 213 +++++ .../WindsurfOutputPlugin.property.test.ts | 383 ++++++++ .../WindsurfOutputPlugin.test.ts | 677 ++++++++++++++ .../plugin-windsurf/WindsurfOutputPlugin.ts | 388 ++++++++ cli/src/plugins/plugin-windsurf/index.ts | 3 + cli/tsconfig.eslint.json | 4 +- cli/tsconfig.json | 68 +- cli/tsconfig.lib.json | 5 +- cli/tsdown.config.ts | 87 +- pnpm-lock.yaml | 613 ------------- 164 files changed, 24435 insertions(+), 698 deletions(-) create mode 100644 cli/scripts/generate-schema.ts create mode 100644 cli/src/plugins/desk-paths/index.property.test.ts create mode 100644 cli/src/plugins/desk-paths/index.ts create mode 100644 cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-agentskills-compact/index.ts create mode 100644 cli/src/plugins/plugin-agentsmd/AgentsOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-agentsmd/index.ts create mode 100644 cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-antigravity/index.ts create mode 100644 cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts create mode 100644 cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts create mode 100644 cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-claude-code-cli/index.ts create mode 100644 cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts create mode 100644 cli/src/plugins/plugin-cursor/CursorOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-cursor/index.ts create mode 100644 cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-droid-cli/index.ts create mode 100644 cli/src/plugins/plugin-editorconfig/EditorConfigOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-editorconfig/index.ts create mode 100644 cli/src/plugins/plugin-gemini-cli/GeminiCLIOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-gemini-cli/index.ts create mode 100644 cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-git-exclude/index.ts create mode 100644 cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-agentskills/index.ts create mode 100644 cli/src/plugins/plugin-input-editorconfig/EditorConfigInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-editorconfig/index.ts create mode 100644 cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-fast-command/index.ts create mode 100644 cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-git-exclude/index.ts create mode 100644 cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-gitignore/index.ts create mode 100644 cli/src/plugins/plugin-input-global-memory/GlobalMemoryInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-global-memory/index.ts create mode 100644 cli/src/plugins/plugin-input-jetbrains-config/JetBrainsConfigInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-jetbrains-config/index.ts create mode 100644 cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.property.test.ts create mode 100644 cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-md-cleanup-effect/index.ts create mode 100644 cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.property.test.ts create mode 100644 cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-orphan-cleanup-effect/index.ts create mode 100644 cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-project-prompt/index.ts create mode 100644 cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.property.test.ts create mode 100644 cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-readme/index.ts create mode 100644 cli/src/plugins/plugin-input-rule/RuleInputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-rule/index.ts create mode 100644 cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-shadow-project/index.ts create mode 100644 cli/src/plugins/plugin-input-shared-ignore/AIAgentIgnoreInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-shared-ignore/index.ts create mode 100644 cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-input-shared/AbstractInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-shared/BaseDirectoryInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-shared/BaseFileInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-shared/index.ts create mode 100644 cli/src/plugins/plugin-input-shared/scope/GlobalScopeCollector.ts create mode 100644 cli/src/plugins/plugin-input-shared/scope/ScopeRegistry.ts create mode 100644 cli/src/plugins/plugin-input-shared/scope/index.ts create mode 100644 cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.property.test.ts create mode 100644 cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-skill-sync-effect/index.ts create mode 100644 cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-subagent/index.ts create mode 100644 cli/src/plugins/plugin-input-vscode-config/VSCodeConfigInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-vscode-config/index.ts create mode 100644 cli/src/plugins/plugin-input-workspace/WorkspaceInputPlugin.ts create mode 100644 cli/src/plugins/plugin-input-workspace/index.ts create mode 100644 cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-jetbrains-ai-codex/index.ts create mode 100644 cli/src/plugins/plugin-jetbrains-codestyle/JetBrainsIDECodeStyleConfigOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-jetbrains-codestyle/index.ts create mode 100644 cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-openai-codex-cli/index.ts create mode 100644 cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts create mode 100644 cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.property.test.ts create mode 100644 cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-opencode-cli/index.ts create mode 100644 cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-output-shared/index.ts create mode 100644 cli/src/plugins/plugin-output-shared/registry/RegistryWriter.ts create mode 100644 cli/src/plugins/plugin-output-shared/registry/index.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/commandFilter.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/gitUtils.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/index.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/pathNormalization.property.test.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/ruleFilter.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/seriesFilter.napi-equivalence.property.test.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/seriesFilter.property.test.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/seriesFilter.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/skillFilter.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/subAgentFilter.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/subSeriesGlobExpansion.property.test.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts create mode 100644 cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.frontmatter.test.ts create mode 100644 cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts create mode 100644 cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-qoder-ide/index.ts create mode 100644 cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.property.test.ts create mode 100644 cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-readme/index.ts create mode 100644 cli/src/plugins/plugin-shared/AbstractPlugin.ts create mode 100644 cli/src/plugins/plugin-shared/PluginNames.ts create mode 100644 cli/src/plugins/plugin-shared/constants.ts create mode 100644 cli/src/plugins/plugin-shared/index.ts create mode 100644 cli/src/plugins/plugin-shared/log.ts create mode 100644 cli/src/plugins/plugin-shared/testing/index.ts create mode 100644 cli/src/plugins/plugin-shared/types/ConfigTypes.schema.property.test.ts create mode 100644 cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts create mode 100644 cli/src/plugins/plugin-shared/types/Enums.ts create mode 100644 cli/src/plugins/plugin-shared/types/Errors.ts create mode 100644 cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts create mode 100644 cli/src/plugins/plugin-shared/types/FileSystemTypes.ts create mode 100644 cli/src/plugins/plugin-shared/types/InputTypes.ts create mode 100644 cli/src/plugins/plugin-shared/types/OutputTypes.ts create mode 100644 cli/src/plugins/plugin-shared/types/PluginTypes.ts create mode 100644 cli/src/plugins/plugin-shared/types/PromptTypes.ts create mode 100644 cli/src/plugins/plugin-shared/types/RegistryTypes.ts create mode 100644 cli/src/plugins/plugin-shared/types/ShadowSourceProjectTypes.ts create mode 100644 cli/src/plugins/plugin-shared/types/index.ts create mode 100644 cli/src/plugins/plugin-shared/types/seriNamePropagation.property.test.ts create mode 100644 cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-trae-ide/index.ts create mode 100644 cli/src/plugins/plugin-vscode/VisualStudioCodeIDEConfigOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-vscode/index.ts create mode 100644 cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-warp-ide/index.ts create mode 100644 cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts create mode 100644 cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.property.test.ts create mode 100644 cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.test.ts create mode 100644 cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-windsurf/index.ts diff --git a/cli/eslint.config.ts b/cli/eslint.config.ts index f4cfccb6..8418913e 100644 --- a/cli/eslint.config.ts +++ b/cli/eslint.config.ts @@ -11,7 +11,7 @@ const config = eslint10({ strictTypescriptEslint: true, tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), parserOptions: { - allowDefaultProject: true + allowDefaultProject: ['*.config.ts'] } }, ignores: [ diff --git a/cli/package.json b/cli/package.json index cb9384a6..12857656 100644 --- a/cli/package.json +++ b/cli/package.json @@ -39,11 +39,12 @@ "registry": "https://registry.npmjs.org/" }, "scripts": { - "build": "run-s build:deps check bundle", + "build": "run-s build:deps check bundle generate:schema", "build:napi": "tsx ../scripts/copy-napi.ts", "build:deps": "pnpm exec turbo run build --filter=...@truenine/memory-sync-cli --filter=!@truenine/memory-sync-cli", "bundle": "pnpm exec tsdown", "check": "run-p typecheck lint", + "generate:schema": "tsx scripts/generate-schema.ts", "lint": "eslint --cache .", "prepublishOnly": "run-s build", "test": "run-s build:deps test:run", @@ -73,51 +74,9 @@ "@truenine/memory-sync-cli-win32-x64-msvc": "workspace:*" }, "devDependencies": { - "@truenine/desk-paths": "workspace:*", "@truenine/init-bundle": "workspace:*", "@truenine/logger": "workspace:*", "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-agentskills-compact": "workspace:*", - "@truenine/plugin-agentsmd": "workspace:*", - "@truenine/plugin-antigravity": "workspace:*", - "@truenine/plugin-claude-code-cli": "workspace:*", - "@truenine/plugin-cursor": "workspace:*", - "@truenine/plugin-droid-cli": "workspace:*", - "@truenine/plugin-editorconfig": "workspace:*", - "@truenine/plugin-gemini-cli": "workspace:*", - "@truenine/plugin-git-exclude": "workspace:*", - "@truenine/plugin-input-agentskills": "workspace:*", - "@truenine/plugin-input-editorconfig": "workspace:*", - "@truenine/plugin-input-fast-command": "workspace:*", - "@truenine/plugin-input-git-exclude": "workspace:*", - "@truenine/plugin-input-gitignore": "workspace:*", - "@truenine/plugin-input-global-memory": "workspace:*", - "@truenine/plugin-input-jetbrains-config": "workspace:*", - "@truenine/plugin-input-md-cleanup-effect": "workspace:*", - "@truenine/plugin-input-orphan-cleanup-effect": "workspace:*", - "@truenine/plugin-input-project-prompt": "workspace:*", - "@truenine/plugin-input-readme": "workspace:*", - "@truenine/plugin-input-rule": "workspace:*", - "@truenine/plugin-input-shadow-project": "workspace:*", - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-input-shared-ignore": "workspace:*", - "@truenine/plugin-input-skill-sync-effect": "workspace:*", - "@truenine/plugin-input-subagent": "workspace:*", - "@truenine/plugin-input-vscode-config": "workspace:*", - "@truenine/plugin-input-workspace": "workspace:*", - "@truenine/plugin-jetbrains-ai-codex": "workspace:*", - "@truenine/plugin-jetbrains-codestyle": "workspace:*", - "@truenine/plugin-kiro-ide": "workspace:*", - "@truenine/plugin-openai-codex-cli": "workspace:*", - "@truenine/plugin-opencode-cli": "workspace:*", - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-qoder-ide": "workspace:*", - "@truenine/plugin-readme": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "@truenine/plugin-trae-ide": "workspace:*", - "@truenine/plugin-vscode": "workspace:*", - "@truenine/plugin-warp-ide": "workspace:*", - "@truenine/plugin-windsurf": "workspace:*", "@types/fs-extra": "catalog:", "@types/picomatch": "catalog:", "@vitest/coverage-v8": "catalog:", diff --git a/cli/scripts/generate-schema.ts b/cli/scripts/generate-schema.ts new file mode 100644 index 00000000..b8c124dc --- /dev/null +++ b/cli/scripts/generate-schema.ts @@ -0,0 +1,5 @@ +import {writeFileSync} from 'node:fs' +import {TNMSC_JSON_SCHEMA} from '../src/schema.ts' + +writeFileSync('./dist/tnmsc.schema.json', `${JSON.stringify(TNMSC_JSON_SCHEMA, null, 2)}\n`, 'utf8') +console.log('Schema generated successfully!') diff --git a/cli/src/plugins/desk-paths/index.property.test.ts b/cli/src/plugins/desk-paths/index.property.test.ts new file mode 100644 index 00000000..da935029 --- /dev/null +++ b/cli/src/plugins/desk-paths/index.property.test.ts @@ -0,0 +1,174 @@ +import type {WriteLogger} from './index' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import * as fc from 'fast-check' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import { + createFileRelativePath, + createRelativePath, + deleteDirectories, + deleteFiles, + ensureDir, + FilePathKind, + readFileSync, + writeFileSafe, + writeFileSync + +} from './index' + +let tmpDir: string + +beforeEach(() => tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'desk-paths-test-'))) + +afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true})) + +/** Generate safe relative path segments (no special chars, no empty) */ +const alphaNum = 'abcdefghijklmnopqrstuvwxyz0123456789' +const safeSegment = fc.array(fc.constantFrom(...alphaNum), {minLength: 1, maxLength: 8}).map(chars => chars.join('')) +const safePath = fc.array(safeSegment, {minLength: 1, maxLength: 4}).map(segs => segs.join('/')) + +describe('ensureDir', () => { // Property 1: ensureDir idempotence + it('property: calling ensureDir multiple times is idempotent', () => { + fc.assert(fc.property(safePath, relPath => { + const dir = path.join(tmpDir, relPath) + ensureDir(dir) + expect(fs.existsSync(dir)).toBe(true) + expect(fs.statSync(dir).isDirectory()).toBe(true) + + ensureDir(dir) // Second call should not throw and dir still exists + expect(fs.existsSync(dir)).toBe(true) + expect(fs.statSync(dir).isDirectory()).toBe(true) + }), {numRuns: 30}) + }) +}) + +describe('writeFileSync / readFileSync', () => { // Property 2: writeFileSync/readFileSync round-trip + it('property: round-trip preserves content', () => { + fc.assert(fc.property(safeSegment, fc.string({minLength: 0, maxLength: 500}), (name, content) => { + const filePath = path.join(tmpDir, `${name}.txt`) + writeFileSync(filePath, content) + const read = readFileSync(filePath) + expect(read).toBe(content) + }), {numRuns: 30}) + }) + + it('property: writeFileSync auto-creates parent directories', () => { + fc.assert(fc.property(safePath, safeSegment, (relDir, name) => { + const filePath = path.join(tmpDir, relDir, `${name}.txt`) + writeFileSync(filePath, 'test') + expect(fs.existsSync(filePath)).toBe(true) + }), {numRuns: 20}) + }) + + it('readFileSync throws with path context on missing file', () => { + const missing = path.join(tmpDir, 'nonexistent.txt') + expect(() => readFileSync(missing)).toThrow(missing) + }) +}) + +describe('deleteFiles', () => { // Property 3: deleteFiles removes all existing files + it('property: deletes all existing files and skips non-existent', () => { + fc.assert(fc.property( + fc.array(safeSegment, {minLength: 1, maxLength: 5}), + names => { + const uniqueNames = [...new Set(names)] + const existingFiles = uniqueNames.map(n => { + const p = path.join(tmpDir, `${n}.txt`) + fs.writeFileSync(p, 'data') + return p + }) + const nonExistent = path.join(tmpDir, 'ghost.txt') + const allFiles = [...existingFiles, nonExistent] + + const result = deleteFiles(allFiles) + expect(result.deleted).toBe(existingFiles.length) + expect(result.errors).toHaveLength(0) + + for (const f of existingFiles) expect(fs.existsSync(f)).toBe(false) + } + ), {numRuns: 20}) + }) +}) + +describe('deleteDirectories', () => { // Property 4: deleteDirectories removes all directories regardless of input order + it('property: removes nested directories in correct order', () => { + fc.assert(fc.property( + fc.array(safeSegment, {minLength: 2, maxLength: 4}), + segments => { + const dirs: string[] = [] // Create nested directory structure + for (let i = 1; i <= segments.length; i++) { + const dir = path.join(tmpDir, ...segments.slice(0, i)) + fs.mkdirSync(dir, {recursive: true}) + dirs.push(dir) + } + + const shuffled = [...dirs].sort(() => Math.random() - 0.5) // Shuffle to test order independence + const result = deleteDirectories(shuffled) + + expect(result.errors).toHaveLength(0) + for (const d of dirs) { // At least the deepest should be deleted; parents may already be gone + expect(fs.existsSync(d)).toBe(false) + } + } + ), {numRuns: 20}) + }) +}) + +describe('createRelativePath', () => { // Property 5: createRelativePath construction correctness + it('property: pathKind is always Relative, path and basePath match inputs', () => { + fc.assert(fc.property(safePath, safePath, (pathStr, basePath) => { + const rp = createRelativePath(pathStr, basePath, () => 'dir') + expect(rp.pathKind).toBe(FilePathKind.Relative) + expect(rp.path).toBe(pathStr) + expect(rp.basePath).toBe(basePath) + expect(rp.getDirectoryName()).toBe('dir') + expect(rp.getAbsolutePath()).toBe(path.join(basePath, pathStr)) + }), {numRuns: 30}) + }) +}) + +describe('createFileRelativePath', () => { // Property 6: createFileRelativePath construction correctness + it('property: file path is parent path joined with filename', () => { + fc.assert(fc.property(safePath, safePath, safeSegment, (dirPath, basePath, fileName) => { + const parent = createRelativePath(dirPath, basePath, () => 'parentDir') + const file = createFileRelativePath(parent, fileName) + + expect(file.pathKind).toBe(FilePathKind.Relative) + expect(file.path).toBe(path.join(dirPath, fileName)) + expect(file.basePath).toBe(basePath) + expect(file.getDirectoryName()).toBe('parentDir') + expect(file.getAbsolutePath()).toBe(path.join(basePath, dirPath, fileName)) + }), {numRuns: 30}) + }) +}) + +describe('writeFileSafe', () => { // Property for writeFileSafe + const noopLogger: WriteLogger = { + trace: () => {}, + error: () => {} + } + + it('property: dry-run never creates files', () => { + fc.assert(fc.property(safeSegment, fc.string({minLength: 1, maxLength: 100}), (name, content) => { + const fullPath = path.join(tmpDir, 'dryrun', `${name}.txt`) + const rp = createRelativePath(`${name}.txt`, path.join(tmpDir, 'dryrun'), () => 'dryrun') + + const result = writeFileSafe({fullPath, content, type: 'test', relativePath: rp, dryRun: true, logger: noopLogger}) + expect(result.success).toBe(true) + expect(result.skipped).toBe(false) + expect(fs.existsSync(fullPath)).toBe(false) + }), {numRuns: 20}) + }) + + it('property: non-dry-run creates files with correct content', () => { + fc.assert(fc.property(safeSegment, fc.string({minLength: 1, maxLength: 100}), (name, content) => { + const fullPath = path.join(tmpDir, 'write', `${name}.txt`) + const rp = createRelativePath(`${name}.txt`, path.join(tmpDir, 'write'), () => 'write') + + const result = writeFileSafe({fullPath, content, type: 'test', relativePath: rp, dryRun: false, logger: noopLogger}) + expect(result.success).toBe(true) + expect(fs.readFileSync(fullPath, 'utf8')).toBe(content) + }), {numRuns: 20}) + }) +}) diff --git a/cli/src/plugins/desk-paths/index.ts b/cli/src/plugins/desk-paths/index.ts new file mode 100644 index 00000000..39ca1f08 --- /dev/null +++ b/cli/src/plugins/desk-paths/index.ts @@ -0,0 +1,401 @@ +import type {Buffer} from 'node:buffer' +import * as fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +/** + * Represents a fixed set of platform directory identifiers. + * + * `PlatformFixedDir` is a type that specifies platform-specific values. + * These values correspond to common operating system platforms and are + * used to identify directory structures or configurations unique to those systems. + * + * Valid values include: + * - 'win32': Represents the Windows operating system. + * - 'darwin': Represents the macOS operating system. + * - 'linux': Represents the Linux operating system. + * + * This type is typically used in contexts where platform-dependent logic + * or directory configurations are required. + */ +type PlatformFixedDir = 'win32' | 'darwin' | 'linux' + +/** + * Determines the Linux data directory based on the XDG_DATA_HOME environment + * variable or defaults to a directory under the user's home directory. + * + * @param {string} homeDir - The home directory path of the current user. + * @return {string} The resolved path to the Linux data directory. + */ +function getLinuxDataDir(homeDir: string): string { + const xdgDataHome = process.env['XDG_DATA_HOME'] + if (typeof xdgDataHome === 'string' && xdgDataHome.trim().length > 0) return xdgDataHome + return path.join(homeDir, '.local', 'share') +} + +/** + * Determines and returns the platform-specific directory for storing application data. + * The directory path is resolved based on the underlying operating system. + * + * @return {string} The resolved directory path specific to the current platform. + * @throws {Error} If the platform is unsupported. + */ +export function getPlatformFixedDir(): string { + const platform = process.platform as PlatformFixedDir + const homeDir = os.homedir() + + if (platform === 'win32') return process.env['LOCALAPPDATA'] ?? path.join(homeDir, 'AppData', 'Local') + if (platform === 'darwin') return path.join(homeDir, 'Library', 'Application Support') + if (platform === 'linux') return getLinuxDataDir(homeDir) + + throw new Error(`Unsupported platform: ${process.platform}`) +} + +/** + * Check if a path is a symbolic link (or junction on Windows). + * + * @param p - The path to check + * @returns true if the path is a symbolic link, false otherwise + */ +export function isSymlink(p: string): boolean { + try { + return fs.lstatSync(p).isSymbolicLink() + } + catch { + return false + } +} + +/** + * Get file stats without following symlinks. + * + * @param p - The path to get stats for + * @returns The fs.Stats object + */ +export function lstatSync(p: string): fs.Stats { + return fs.lstatSync(p) +} + +/** + * Ensure a directory exists, creating it recursively if needed. + * Idempotent: calling multiple times has the same effect as calling once. + * + * @param dir - The directory path to ensure exists + */ +export function ensureDir(dir: string): void { + fs.mkdirSync(dir, {recursive: true}) +} + +/** @internal */ +function ensureDirectory(dir: string): void { + ensureDir(dir) +} + +/** + * Create a symbolic link with cross-platform support. + * + * On Windows: + * - Uses 'junction' for directories (no admin privileges required) + * - Uses 'file' symlink for files (may require admin or developer mode) + * + * On Unix/macOS: + * - Uses standard symbolic links for both files and directories + * + * @param targetPath - The path the symlink should point to (must be absolute on Windows for junction) + * @param symlinkPath - The path where the symlink will be created + * @param type - Type of symlink: 'file' or 'dir' (default: 'dir') + */ +export function createSymlink(targetPath: string, symlinkPath: string, type: 'file' | 'dir' = 'dir'): void { + const parentDir = path.dirname(symlinkPath) + ensureDirectory(parentDir) + + if (fs.existsSync(symlinkPath)) { // Remove existing symlink or directory + const stat = fs.lstatSync(symlinkPath) + if (stat.isSymbolicLink()) { + if (process.platform === 'win32') fs.rmSync(symlinkPath, {recursive: true, force: true}) // Windows junction needs rmSync + else fs.unlinkSync(symlinkPath) + } else if (stat.isDirectory()) fs.rmSync(symlinkPath, {recursive: true}) + else fs.unlinkSync(symlinkPath) + } + + if (process.platform === 'win32' && type === 'dir') fs.symlinkSync(targetPath, symlinkPath, 'junction') // On Windows, use junction for directories (no admin needed) + else fs.symlinkSync(targetPath, symlinkPath, type) +} + +/** + * Remove a symbolic link (or junction on Windows) if it exists. + * + * @param symlinkPath - The path of the symlink to remove + */ +export function removeSymlink(symlinkPath: string): void { + if (!fs.existsSync(symlinkPath)) return + + const stat = fs.lstatSync(symlinkPath) + if (stat.isSymbolicLink()) { + if (process.platform === 'win32') fs.rmSync(symlinkPath, {recursive: true, force: true}) // Windows junction needs rmSync + else fs.unlinkSync(symlinkPath) + } +} + +/** + * Read the target of a symbolic link. + * + * @param symlinkPath - The path of the symlink + * @returns The target path, or null if not a symlink or an error occurred + */ +export function readSymlinkTarget(symlinkPath: string): string | null { + try { + if (!isSymlink(symlinkPath)) return null + return fs.readlinkSync(symlinkPath) + } + catch { + return null + } +} + +/** + * Check if a path exists (file, directory, or symlink). + * + * @param p - The path to check + * @returns true if the path exists + */ +export function existsSync(p: string): boolean { + return fs.existsSync(p) +} + +/** + * Delete a file, directory, or symlink/junction safely. + * Handles Windows junctions properly by using rmSync. + * + * @param p - The path to delete + */ +export function deletePathSync(p: string): void { + if (!fs.existsSync(p)) return + + const stat = fs.lstatSync(p) + if (stat.isSymbolicLink()) { + if (process.platform === 'win32') fs.rmSync(p, {recursive: true, force: true}) // Windows junction + else fs.unlinkSync(p) + } else if (stat.isDirectory()) fs.rmSync(p, {recursive: true, force: true}) + else fs.unlinkSync(p) +} // File Operations - Read, Write, Ensure + +/** + * Write a string or Buffer to a file, auto-creating parent directories. + * + * @param filePath - Absolute path to the file + * @param data - Content to write (string or Buffer) + * @param encoding - Encoding for string data (default: 'utf8') + */ +export function writeFileSync(filePath: string, data: string | Buffer, encoding: BufferEncoding = 'utf8'): void { + const parentDir = path.dirname(filePath) + ensureDir(parentDir) + if (typeof data === 'string') fs.writeFileSync(filePath, data, encoding) + else fs.writeFileSync(filePath, data) +} + +/** + * Read a file as a string. Throws with the path included in the error message on failure. + * + * @param filePath - Absolute path to the file + * @param encoding - Encoding (default: 'utf8') + * @returns The file content as a string + * @throws Error with path context if the file cannot be read + */ +export function readFileSync(filePath: string, encoding: BufferEncoding = 'utf8'): string { + try { + return fs.readFileSync(filePath, encoding) + } + catch (error) { + const msg = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to read file "${filePath}": ${msg}`) + } +} // Batch Deletion - Delete files and directories with error collection + +/** + * Error encountered during a batch deletion operation. + */ +export interface DeletionError { + readonly path: string + readonly error: unknown +} + +/** + * Result of a batch deletion operation. + */ +export interface DeletionResult { + readonly deleted: number + readonly errors: readonly DeletionError[] +} + +/** + * Delete multiple files. Skips non-existent files. Collects errors without throwing. + * + * @param files - Array of absolute file paths to delete + * @returns DeletionResult with count and errors + */ +export function deleteFiles(files: readonly string[]): DeletionResult { + let deleted = 0 + const errors: DeletionError[] = [] + + for (const file of files) { + try { + if (fs.existsSync(file)) { + deletePathSync(file) + deleted++ + } + } + catch (e) { + errors.push({path: file, error: e}) + } + } + + return {deleted, errors} +} + +/** + * Delete multiple directories. Sorts by depth descending so nested dirs are removed first. + * Skips non-existent directories. Collects errors without throwing. + * + * @param dirs - Array of absolute directory paths to delete + * @returns DeletionResult with count and errors + */ +export function deleteDirectories(dirs: readonly string[]): DeletionResult { + let deleted = 0 + const errors: DeletionError[] = [] + + const sorted = [...dirs].sort((a, b) => b.length - a.length) + + for (const dir of sorted) { + try { + if (fs.existsSync(dir)) { + fs.rmSync(dir, {recursive: true, force: true}) + deleted++ + } + } + catch (e) { + errors.push({path: dir, error: e}) + } + } + + return {deleted, errors} +} // RelativePath Factory - Construct RelativePath objects + +/** + * Directory path kind discriminator. + */ +export enum FilePathKind { + Relative = 'Relative', + Absolute = 'Absolute', + Root = 'Root' +} + +/** + * A path relative to a base directory. + */ +export interface RelativePath { + readonly pathKind: FilePathKind.Relative + readonly path: string + readonly basePath: string + readonly getDirectoryName: () => string + readonly getAbsolutePath: () => string +} + +/** + * Create a RelativePath from a path string, base path, and directory name function. + * + * @param pathStr - The relative path string + * @param basePath - The base directory for absolute path resolution + * @param dirNameFn - Function returning the directory name + * @returns A RelativePath object + */ +export function createRelativePath( + pathStr: string, + basePath: string, + dirNameFn: () => string +): RelativePath { + return { + pathKind: FilePathKind.Relative, + path: pathStr, + basePath, + getDirectoryName: dirNameFn, + getAbsolutePath: () => path.join(basePath, pathStr) + } +} + +/** + * Create a RelativePath for a file within a parent directory. + * The getDirectoryName delegates to the parent directory's getDirectoryName. + * + * @param dir - Parent directory RelativePath + * @param fileName - Name of the file + * @returns A RelativePath pointing to the file + */ +export function createFileRelativePath(dir: RelativePath, fileName: string): RelativePath { + const filePath = path.join(dir.path, fileName) + return { + pathKind: FilePathKind.Relative, + path: filePath, + basePath: dir.basePath, + getDirectoryName: () => dir.getDirectoryName(), + getAbsolutePath: () => path.join(dir.basePath, filePath) + } +} // Safe Write - Dry-run aware file writing with error handling + +/** + * Logger interface for safe write operations. + */ +export interface WriteLogger { + readonly trace: (data: object) => void + readonly error: (data: object) => void +} + +/** + * Options for writeFileSafe. + */ +export interface SafeWriteOptions { + readonly fullPath: string + readonly content: string | Buffer + readonly type: string + readonly relativePath: RelativePath + readonly dryRun: boolean + readonly logger: WriteLogger +} + +/** + * Result of a safe write operation. + */ +export interface SafeWriteResult { + readonly path: RelativePath + readonly success: boolean + readonly skipped?: boolean + readonly error?: Error +} + +/** + * Write a file with dry-run support and error handling. + * Auto-creates parent directories. Returns a result object instead of throwing. + * + * @param options - Write options including path, content, dry-run flag, and logger + * @returns SafeWriteResult indicating success or failure + */ +export function writeFileSafe(options: SafeWriteOptions): SafeWriteResult { + const {fullPath, content, type, relativePath, dryRun, logger} = options + + if (dryRun) { + logger.trace({action: 'dryRun', type, path: fullPath}) + return {path: relativePath, success: true, skipped: false} + } + + try { + writeFileSync(fullPath, content) + logger.trace({action: 'write', type, path: fullPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + logger.error({action: 'write', type, path: fullPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } +} diff --git a/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts new file mode 100644 index 00000000..f09ed44c --- /dev/null +++ b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts @@ -0,0 +1,447 @@ +import type { + CollectedInputContext, + OutputPluginContext, + OutputWriteContext, + RelativePath, + SkillChildDoc, + SkillPrompt, + SkillResource, + SkillYAMLFrontMatter +} from '@truenine/plugin-shared' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import {FilePathKind, PromptKind} from '@truenine/plugin-shared' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {GenericSkillsOutputPlugin} from './GenericSkillsOutputPlugin' + +vi.mock('node:fs') +vi.mock('node:os') + +describe('genericSkillsOutputPlugin', () => { + const mockWorkspaceDir = '/workspace/test' + const mockHomeDir = '/home/user' + let plugin: GenericSkillsOutputPlugin + + beforeEach(() => { + plugin = new GenericSkillsOutputPlugin() + vi.mocked(os.homedir).mockReturnValue(mockHomeDir) + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.mkdirSync).mockReturnValue(void 0) + vi.mocked(fs.writeFileSync).mockReturnValue(void 0) + vi.mocked(fs.symlinkSync).mockReturnValue(void 0) + vi.mocked(fs.lstatSync).mockReturnValue({isSymbolicLink: () => false, isDirectory: () => false} as fs.Stats) + }) + + 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 createMockSkillPrompt( + name: string, + content: string, + options?: { + description?: string + keywords?: readonly string[] + displayName?: string + author?: string + version?: string + mcpConfig?: {rawContent: string} + childDocs?: {relativePath: string, content: string}[] + resources?: {relativePath: string, content: string, encoding: 'text' | 'base64'}[] + } + ): SkillPrompt { + const yamlFrontMatter: SkillYAMLFrontMatter = { + name, + description: options?.description ?? `Description for ${name}`, + namingCase: 0 as any, + keywords: options?.keywords ?? [], + displayName: options?.displayName ?? name, + author: options?.author ?? '', + version: options?.version ?? '' + } + + const childDocs: SkillChildDoc[] | undefined = options?.childDocs?.map(d => ({ + type: PromptKind.SkillChildDoc, + relativePath: d.relativePath, + content: d.content, + markdownContents: [], + dir: createMockRelativePath(d.relativePath, '/shadow/.skills'), + length: d.content.length, + filePathKind: FilePathKind.Relative + })) + + const resources: SkillResource[] | undefined = options?.resources?.map(r => ({ + type: PromptKind.SkillResource, + relativePath: r.relativePath, + content: r.content, + encoding: r.encoding, + extension: path.extname(r.relativePath), + fileName: path.basename(r.relativePath), + category: 'other' as const, + length: r.content.length + })) + + return { + type: PromptKind.Skill, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + markdownContents: [], + yamlFrontMatter, + dir: createMockRelativePath(name, '/shadow/.skills'), + mcpConfig: options?.mcpConfig != null + ? { + type: PromptKind.SkillMcpConfig, + mcpServers: {}, + rawContent: options.mcpConfig.rawContent + } + : void 0, + childDocs, + resources + } as unknown as SkillPrompt + } + + function createMockOutputWriteContext( + collectedInputContext: Partial, + dryRun = false + ): OutputWriteContext { + return { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [] + }, + ideConfigFiles: [], + ...collectedInputContext + } as CollectedInputContext, + dryRun, + registeredPluginNames: [], + logger: {trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, + fs, + path, + glob: vi.fn() as any + } + } + + function createMockOutputPluginContext( + collectedInputContext: Partial + ): OutputPluginContext { + return { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [] + }, + ideConfigFiles: [], + ...collectedInputContext + } as CollectedInputContext, + logger: {trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, + fs, + path, + glob: vi.fn() as any + } + } + + describe('canWrite', () => { + it('should return false when no skills exist', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'test-project', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + } + }) + + const result = await plugin.canWrite(ctx) + expect(result).toBe(false) + }) + + it('should return false when no projects exist', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [] + }, + skills: [createMockSkillPrompt('test-skill', 'content')] + }) + + const result = await plugin.canWrite(ctx) + expect(result).toBe(false) + }) + + it('should return true when skills and projects exist', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'test-project', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + }, + skills: [createMockSkillPrompt('test-skill', 'content')] + }) + + const result = await plugin.canWrite(ctx) + expect(result).toBe(true) + }) + }) + + describe('registerProjectOutputDirs', () => { + it('should register .skills directory for each project', async () => { + const ctx = createMockOutputPluginContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + {name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}, + {name: 'project2', dirFromWorkspacePath: createMockRelativePath('project2', mockWorkspaceDir)} + ] + }, + skills: [createMockSkillPrompt('test-skill', 'content')] + }) + + const results = await plugin.registerProjectOutputDirs(ctx) + + expect(results).toHaveLength(2) + expect(results[0]?.path).toBe(path.join('project1', '.skills')) + expect(results[1]?.path).toBe(path.join('project2', '.skills')) + }) + + it('should return empty array when no skills exist', async () => { + const ctx = createMockOutputPluginContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + } + }) + + const results = await plugin.registerProjectOutputDirs(ctx) + expect(results).toHaveLength(0) + }) + }) + + describe('registerGlobalOutputDirs', () => { + it('should register ~/.skills/ directory when skills exist', async () => { + const ctx = createMockOutputPluginContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + }, + skills: [createMockSkillPrompt('test-skill', 'content')] + }) + + const results = await plugin.registerGlobalOutputDirs(ctx) + + expect(results).toHaveLength(1) + const pathValue = results[0]?.path.replaceAll('\\', '/') + const expected = path.join('.aindex', '.skills').replaceAll('\\', '/') + expect(pathValue).toBe(expected) + expect(results[0]?.basePath).toBe(mockHomeDir) + }) + + it('should return empty array when no skills exist', async () => { + const ctx = createMockOutputPluginContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + } + }) + + const results = await plugin.registerGlobalOutputDirs(ctx) + expect(results).toHaveLength(0) + }) + }) + + describe('registerGlobalOutputFiles', () => { + it('should register SKILL.md in ~/.skills/ for each skill', async () => { + const ctx = createMockOutputPluginContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + }, + skills: [ + createMockSkillPrompt('skill-a', 'content a'), + createMockSkillPrompt('skill-b', 'content b') + ] + }) + + const results = await plugin.registerGlobalOutputFiles(ctx) + + expect(results).toHaveLength(2) + expect(results[0]?.path).toBe(path.join('.aindex', '.skills', 'skill-a', 'SKILL.md')) + expect(results[1]?.path).toBe(path.join('.aindex', '.skills', 'skill-b', 'SKILL.md')) + expect(results[0]?.basePath).toBe(mockHomeDir) + }) + + it('should register mcp.json when skill has MCP config', async () => { + const ctx = createMockOutputPluginContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + }, + skills: [createMockSkillPrompt('test-skill', 'content', {mcpConfig: {rawContent: '{}'}})] + }) + + const results = await plugin.registerGlobalOutputFiles(ctx) + + expect(results).toHaveLength(2) + expect(results[0]?.path).toBe(path.join('.aindex', '.skills', 'test-skill', 'SKILL.md')) + expect(results[1]?.path).toBe(path.join('.aindex', '.skills', 'test-skill', 'mcp.json')) + }) + }) + + describe('writeGlobalOutputs', () => { + it('should write SKILL.md to ~/.skills/ with front matter', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + }, + skills: [createMockSkillPrompt('test-skill', '# Skill Content', { + description: 'A test skill', + keywords: ['test', 'demo'] + })] + }) + + const results = await plugin.writeGlobalOutputs(ctx) + + expect(results.files).toHaveLength(1) + expect(results.files[0]?.success).toBe(true) + + const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] + expect(writeCall).toBeDefined() + expect(writeCall?.[0]).toContain(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill', 'SKILL.md')) + + const writtenContent = writeCall?.[1] as string + expect(writtenContent).toContain('name: test-skill') + expect(writtenContent).toContain('description: A test skill') + expect(writtenContent).toContain('# Skill Content') + }) + + it('should support dry-run mode', async () => { + const ctx = createMockOutputWriteContext( + { + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + }, + skills: [createMockSkillPrompt('test-skill', 'content')] + }, + true + ) + + const results = await plugin.writeGlobalOutputs(ctx) + + expect(results.files).toHaveLength(1) + expect(results.files[0]?.success).toBe(true) + expect(results.files[0]?.skipped).toBe(false) + expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() + }) + }) + + describe('writeProjectOutputs', () => { + it('should create symlinks for each skill in each project', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false) // Symlink doesn't exist yet + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + {name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}, + {name: 'project2', dirFromWorkspacePath: createMockRelativePath('project2', mockWorkspaceDir)} + ] + }, + skills: [createMockSkillPrompt('test-skill', 'content')] + }) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files).toHaveLength(2) + expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledTimes(2) + + expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledWith( // Verify symlinks point from project to global + expect.stringContaining(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill')), + expect.stringContaining(path.join('project1', '.skills', 'test-skill')), + expect.anything() + ) + expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledWith( + expect.stringContaining(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill')), + expect.stringContaining(path.join('project2', '.skills', 'test-skill')), + expect.anything() + ) + }) + + it('should support dry-run mode for symlinks', async () => { + const ctx = createMockOutputWriteContext( + { + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + }, + skills: [createMockSkillPrompt('test-skill', 'content')] + }, + true + ) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files).toHaveLength(1) + expect(results.files[0]?.success).toBe(true) + expect(results.files[0]?.skipped).toBe(false) + expect(vi.mocked(fs.symlinkSync)).not.toHaveBeenCalled() + }) + + it('should skip project without dirFromWorkspacePath', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1'}] + }, + skills: [createMockSkillPrompt('test-skill', 'content')] + }) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files).toHaveLength(0) + expect(vi.mocked(fs.symlinkSync)).not.toHaveBeenCalled() + }) + + it('should return empty results when no skills exist', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + } + }) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files).toHaveLength(0) + expect(results.dirs).toHaveLength(0) + }) + }) + + describe('registerProjectOutputFiles', () => { + it('should register symlink paths for each skill in each project', async () => { + const ctx = createMockOutputPluginContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + }, + skills: [ + createMockSkillPrompt('skill-a', 'content a'), + createMockSkillPrompt('skill-b', 'content b') + ] + }) + + const results = await plugin.registerProjectOutputFiles(ctx) + + expect(results).toHaveLength(2) + expect(results[0]?.path).toBe(path.join('.skills', 'skill-a')) + expect(results[1]?.path).toBe(path.join('.skills', 'skill-b')) + }) + }) +}) diff --git a/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts new file mode 100644 index 00000000..8a53d64e --- /dev/null +++ b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts @@ -0,0 +1,468 @@ +import type { + OutputPluginContext, + OutputWriteContext, + SkillPrompt, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' + +import {Buffer} from 'node:buffer' +import * as fs from 'node:fs' +import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {FilePathKind} from '@truenine/plugin-shared' + +const PROJECT_SKILLS_DIR = '.skills' +const GLOBAL_SKILLS_DIR = '.aindex/.skills' +const OLD_GLOBAL_SKILLS_DIR = '.skills' // 向后兼容:旧的全局 skills 目录 +const SKILL_FILE_NAME = 'SKILL.md' +const MCP_CONFIG_FILE = 'mcp.json' + +/** + * Output plugin that writes skills to a global location (~/.skills/) and + * creates symlinks in each project pointing to the global skill directories. + * + * This approach reduces disk space usage when multiple projects use the same skills. + * + * Structure: + * - Global: ~/.skills//SKILL.md, mcp.json, child docs, resources + * - Project: /.skills/ → ~/.skills/ (symlink) + */ +export class GenericSkillsOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('GenericSkillsOutputPlugin', {globalConfigDir: GLOBAL_SKILLS_DIR, outputFileName: SKILL_FILE_NAME}) + + this.registerCleanEffect('legacy-global-skills-cleanup', async ctx => { // 向后兼容:clean 时清理旧的 ~/.skills 目录 + const oldGlobalSkillsDir = this.joinPath(this.getHomeDir(), OLD_GLOBAL_SKILLS_DIR) + if (!this.existsSync(oldGlobalSkillsDir)) return {success: true, description: 'Legacy global skills dir does not exist, nothing to clean'} + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'legacyCleanup', path: oldGlobalSkillsDir}) + return {success: true, description: `Would clean legacy global skills dir: ${oldGlobalSkillsDir}`} + } + try { + const entries = this.readdirSync(oldGlobalSkillsDir, {withFileTypes: true}) // 只删除 skill 子目录(避免误删用户其他文件) + let cleanedCount = 0 + for (const entry of entries) { + if (entry.isDirectory()) { + const skillDir = this.joinPath(oldGlobalSkillsDir, entry.name) + const skillFile = this.joinPath(skillDir, SKILL_FILE_NAME) + if (this.existsSync(skillFile)) { // 确认是 skill 目录(包含 SKILL.md)才删除 + fs.rmSync(skillDir, {recursive: true}) + cleanedCount++ + } + } + } + const remainingEntries = this.readdirSync(oldGlobalSkillsDir) // 如果目录为空则删除目录本身 + if (remainingEntries.length === 0) fs.rmdirSync(oldGlobalSkillsDir) + this.log.trace({action: 'clean', type: 'legacySkills', dir: oldGlobalSkillsDir, cleanedCount}) + return {success: true, description: `Cleaned ${cleanedCount} legacy skills from ${oldGlobalSkillsDir}`} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'clean', type: 'legacySkills', dir: oldGlobalSkillsDir, error: errMsg}) + return {success: false, description: `Failed to clean legacy skills dir`, error: error as Error} + } + }) + } + + private getGlobalSkillsDir(): string { + return this.joinPath(this.getHomeDir(), GLOBAL_SKILLS_DIR) + } + + async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + const {skills} = ctx.collectedInputContext + + if (skills == null || skills.length === 0) return results + + for (const project of projects) { // Register /.skills/ for cleanup (symlink directory) + if (project.dirFromWorkspacePath == null) continue + + const skillsDir = this.joinPath(project.dirFromWorkspacePath.path, PROJECT_SKILLS_DIR) + results.push({ + pathKind: FilePathKind.Relative, + path: skillsDir, + basePath: project.dirFromWorkspacePath.basePath, + getDirectoryName: () => PROJECT_SKILLS_DIR, + getAbsolutePath: () => this.joinPath(project.dirFromWorkspacePath!.basePath, skillsDir) + }) + } + + return results + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + const {skills} = ctx.collectedInputContext + + if (skills == null || skills.length === 0) return results + + for (const project of projects) { // Register symlink paths (skills in project are now symlinks) + if (project.dirFromWorkspacePath == null) continue + + const projectSkillsDir = this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path, PROJECT_SKILLS_DIR) + + for (const skill of skills) { + const skillName = skill.yamlFrontMatter.name + const skillDir = this.joinPath(projectSkillsDir, skillName) + + results.push({ // Register skill directory symlink + pathKind: FilePathKind.Relative, + path: this.joinPath(PROJECT_SKILLS_DIR, skillName), + basePath: this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path), + getDirectoryName: () => skillName, + getAbsolutePath: () => skillDir + }) + } + } + + return results + } + + async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { + const {skills} = ctx.collectedInputContext + + if (skills == null || skills.length === 0) return [] + + const globalSkillsDir = this.getGlobalSkillsDir() + return [{ + pathKind: FilePathKind.Relative, + path: GLOBAL_SKILLS_DIR, + basePath: this.getHomeDir(), + getDirectoryName: () => GLOBAL_SKILLS_DIR, + getAbsolutePath: () => globalSkillsDir + }] + } + + async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {skills} = ctx.collectedInputContext + + if (skills == null || skills.length === 0) return results + + const globalSkillsDir = this.getGlobalSkillsDir() + + for (const skill of skills) { + const skillName = skill.yamlFrontMatter.name + const skillDir = this.joinPath(globalSkillsDir, skillName) + + results.push({ // Register SKILL.md + pathKind: FilePathKind.Relative, + path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, SKILL_FILE_NAME), + basePath: this.getHomeDir(), + getDirectoryName: () => skillName, + getAbsolutePath: () => this.joinPath(skillDir, SKILL_FILE_NAME) + }) + + if (skill.mcpConfig != null) { // Register mcp.json if skill has MCP configuration + results.push({ + pathKind: FilePathKind.Relative, + path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, MCP_CONFIG_FILE), + basePath: this.getHomeDir(), + getDirectoryName: () => skillName, + getAbsolutePath: () => this.joinPath(skillDir, MCP_CONFIG_FILE) + }) + } + + if (skill.childDocs != null) { // Register child docs (convert .mdx to .md) + for (const childDoc of skill.childDocs) { + const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') + results.push({ + pathKind: FilePathKind.Relative, + path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, outputRelativePath), + basePath: this.getHomeDir(), + getDirectoryName: () => skillName, + getAbsolutePath: () => this.joinPath(skillDir, outputRelativePath) + }) + } + } + + if (skill.resources != null) { // Register resources + for (const resource of skill.resources) { + results.push({ + pathKind: FilePathKind.Relative, + path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, resource.relativePath), + basePath: this.getHomeDir(), + getDirectoryName: () => skillName, + getAbsolutePath: () => this.joinPath(skillDir, resource.relativePath) + }) + } + } + } + + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {skills} = ctx.collectedInputContext + const {projects} = ctx.collectedInputContext.workspace + + if (skills == null || skills.length === 0) { + this.log.trace({action: 'skip', reason: 'noSkills'}) + return false + } + + if (projects.length !== 0) return true + + this.log.trace({action: 'skip', reason: 'noProjects'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + const {skills} = ctx.collectedInputContext + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + if (skills == null || skills.length === 0) return {files: fileResults, dirs: dirResults} + + const globalSkillsDir = this.getGlobalSkillsDir() + + for (const project of projects) { // Create symlinks for each project + if (project.dirFromWorkspacePath == null) continue + + const projectSkillsDir = this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path, PROJECT_SKILLS_DIR) + + for (const skill of skills) { + const skillName = skill.yamlFrontMatter.name + const globalSkillDir = this.joinPath(globalSkillsDir, skillName) + const projectSkillDir = this.joinPath(projectSkillsDir, skillName) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: this.joinPath(PROJECT_SKILLS_DIR, skillName), + basePath: this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path), + getDirectoryName: () => skillName, + getAbsolutePath: () => projectSkillDir + } + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'symlink', target: globalSkillDir, link: projectSkillDir}) + fileResults.push({path: relativePath, success: true, skipped: false}) + continue + } + + try { + this.createSymlink(globalSkillDir, projectSkillDir, 'dir') + this.log.trace({action: 'symlink', type: 'skill', target: globalSkillDir, link: projectSkillDir}) + fileResults.push({path: relativePath, success: true}) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'symlink', type: 'skill', target: globalSkillDir, link: projectSkillDir, error: errMsg}) + fileResults.push({path: relativePath, success: false, error: error as Error}) + } + } + } + + return {files: fileResults, dirs: dirResults} + } + + async writeGlobalOutputs(ctx: OutputWriteContext): Promise { + const {skills} = ctx.collectedInputContext + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + if (skills == null || skills.length === 0) return {files: fileResults, dirs: dirResults} + + const globalSkillsDir = this.getGlobalSkillsDir() + + for (const skill of skills) { // Write all skills to global ~/.skills/ directory + const skillResults = await this.writeSkill(ctx, skill, globalSkillsDir) + fileResults.push(...skillResults) + } + + return {files: fileResults, dirs: dirResults} + } + + private async writeSkill( + ctx: OutputWriteContext, + skill: SkillPrompt, + skillsDir: string + ): Promise { + const results: WriteResult[] = [] + const skillName = skill.yamlFrontMatter.name + const skillDir = this.joinPath(skillsDir, skillName) + const skillFilePath = this.joinPath(skillDir, SKILL_FILE_NAME) + + const skillRelativePath: RelativePath = { // Create RelativePath for SKILL.md + pathKind: FilePathKind.Relative, + path: SKILL_FILE_NAME, + basePath: skillDir, + getDirectoryName: () => skillName, + getAbsolutePath: () => skillFilePath + } + + const frontMatterData = this.buildSkillFrontMatter(skill) // Build SKILL.md content with front matter + const bodyContent = skill.content as string + const skillContent = buildMarkdownWithFrontMatter(frontMatterData, bodyContent) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'skill', path: skillFilePath}) + results.push({path: skillRelativePath, success: true, skipped: false}) + } else { + try { + this.ensureDirectory(skillDir) + this.writeFileSync(skillFilePath, skillContent) + this.log.trace({action: 'write', type: 'skill', path: skillFilePath}) + results.push({path: skillRelativePath, success: true}) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'skill', path: skillFilePath, error: errMsg}) + results.push({path: skillRelativePath, success: false, error: error as Error}) + } + } + + if (skill.mcpConfig != null) { // Write mcp.json if skill has MCP configuration + const mcpResult = await this.writeMcpConfig(ctx, skill, skillDir) + results.push(mcpResult) + } + + if (skill.childDocs != null) { // Write child docs + for (const childDoc of skill.childDocs) { + const childDocResult = await this.writeChildDoc(ctx, childDoc, skillDir, skillName) + results.push(childDocResult) + } + } + + if (skill.resources != null) { // Write resources + for (const resource of skill.resources) { + const resourceResult = await this.writeResource(ctx, resource, skillDir, skillName) + results.push(resourceResult) + } + } + + return results + } + + private buildSkillFrontMatter(skill: SkillPrompt): Record { + const fm = skill.yamlFrontMatter + return { + name: fm.name, + description: fm.description, + ...fm.displayName != null && {displayName: fm.displayName}, + ...fm.keywords != null && fm.keywords.length > 0 && {keywords: fm.keywords}, + ...fm.author != null && {author: fm.author}, + ...fm.version != null && {version: fm.version}, + ...fm.allowTools != null && fm.allowTools.length > 0 && {allowTools: fm.allowTools} + } + } + + private async writeMcpConfig( + ctx: OutputWriteContext, + skill: SkillPrompt, + skillDir: string + ): Promise { + const skillName = skill.yamlFrontMatter.name + const mcpConfigPath = this.joinPath(skillDir, MCP_CONFIG_FILE) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: MCP_CONFIG_FILE, + basePath: skillDir, + getDirectoryName: () => skillName, + getAbsolutePath: () => mcpConfigPath + } + + const mcpConfigContent = skill.mcpConfig!.rawContent + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'mcpConfig', path: mcpConfigPath}) + return {path: relativePath, success: true, skipped: false} + } + + try { + this.ensureDirectory(skillDir) + this.writeFileSync(mcpConfigPath, mcpConfigContent) + this.log.trace({action: 'write', type: 'mcpConfig', path: mcpConfigPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'mcpConfig', path: mcpConfigPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private async writeChildDoc( + ctx: OutputWriteContext, + childDoc: {relativePath: string, content: unknown}, + skillDir: string, + skillName: string + ): Promise { + const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') // Convert .mdx to .md for output + const childDocPath = this.joinPath(skillDir, outputRelativePath) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: outputRelativePath, + basePath: skillDir, + getDirectoryName: () => skillName, + getAbsolutePath: () => childDocPath + } + + const content = childDoc.content as string + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'childDoc', path: childDocPath}) + return {path: relativePath, success: true, skipped: false} + } + + try { + const parentDir = this.dirname(childDocPath) + this.ensureDirectory(parentDir) + this.writeFileSync(childDocPath, content) + this.log.trace({action: 'write', type: 'childDoc', path: childDocPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'childDoc', path: childDocPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private async writeResource( + ctx: OutputWriteContext, + resource: {relativePath: string, content: string, encoding: 'text' | 'base64'}, + skillDir: string, + skillName: string + ): Promise { + const resourcePath = this.joinPath(skillDir, resource.relativePath) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: resource.relativePath, + basePath: skillDir, + getDirectoryName: () => skillName, + getAbsolutePath: () => resourcePath + } + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'resource', path: resourcePath}) + return {path: relativePath, success: true, skipped: false} + } + + try { + const parentDir = this.dirname(resourcePath) + this.ensureDirectory(parentDir) + + if (resource.encoding === 'base64') { // Handle binary vs text encoding + const buffer = Buffer.from(resource.content, 'base64') + this.writeFileSyncBuffer(resourcePath, buffer) + } else this.writeFileSync(resourcePath, resource.content) + + this.log.trace({action: 'write', type: 'resource', path: resourcePath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'resource', path: resourcePath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } +} diff --git a/cli/src/plugins/plugin-agentskills-compact/index.ts b/cli/src/plugins/plugin-agentskills-compact/index.ts new file mode 100644 index 00000000..abe6e9b6 --- /dev/null +++ b/cli/src/plugins/plugin-agentskills-compact/index.ts @@ -0,0 +1,3 @@ +export { + GenericSkillsOutputPlugin +} from './GenericSkillsOutputPlugin' diff --git a/cli/src/plugins/plugin-agentsmd/AgentsOutputPlugin.ts b/cli/src/plugins/plugin-agentsmd/AgentsOutputPlugin.ts new file mode 100644 index 00000000..c0c9a835 --- /dev/null +++ b/cli/src/plugins/plugin-agentsmd/AgentsOutputPlugin.ts @@ -0,0 +1,74 @@ +import type { + OutputPluginContext, + OutputWriteContext, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' + +const PROJECT_MEMORY_FILE = 'AGENTS.md' + +export class AgentsOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('AgentsOutputPlugin', {outputFileName: PROJECT_MEMORY_FILE}) + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + + for (const project of projects) { + if (project.rootMemoryPrompt != null && project.dirFromWorkspacePath != null) { // Root memory prompt uses project.dirFromWorkspacePath + results.push(this.createFileRelativePath(project.dirFromWorkspacePath, PROJECT_MEMORY_FILE)) + } + + if (project.childMemoryPrompts != null) { + for (const child of project.childMemoryPrompts) { + if (child.dir != null && this.isRelativePath(child.dir)) results.push(this.createFileRelativePath(child.dir, PROJECT_MEMORY_FILE)) + } + } + } + + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {workspace} = ctx.collectedInputContext + const hasProjectOutputs = workspace.projects.some( + p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 + ) + + if (hasProjectOutputs) return true + + this.log.trace({action: 'skip', reason: 'noOutputs'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + for (const project of projects) { + const projectName = project.name ?? 'unknown' + const projectDir = project.dirFromWorkspacePath + + if (projectDir == null) continue + + if (project.rootMemoryPrompt != null) { // Write root memory prompt (only if exists) + const result = await this.writePromptFile(ctx, projectDir, project.rootMemoryPrompt.content as string, `project:${projectName}/root`) + fileResults.push(result) + } + + if (project.childMemoryPrompts != null) { // Write children memory prompts + for (const child of project.childMemoryPrompts) { + const childResult = await this.writePromptFile(ctx, child.dir, child.content as string, `project:${projectName}/child:${child.workingChildDirectoryPath?.path ?? 'unknown'}`) + fileResults.push(childResult) + } + } + } + + return {files: fileResults, dirs: dirResults} + } +} diff --git a/cli/src/plugins/plugin-agentsmd/index.ts b/cli/src/plugins/plugin-agentsmd/index.ts new file mode 100644 index 00000000..2a8505e4 --- /dev/null +++ b/cli/src/plugins/plugin-agentsmd/index.ts @@ -0,0 +1,3 @@ +export { + AgentsOutputPlugin +} from './AgentsOutputPlugin' diff --git a/cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.test.ts b/cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.test.ts new file mode 100644 index 00000000..6884c8c9 --- /dev/null +++ b/cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.test.ts @@ -0,0 +1,343 @@ +import type {RelativePath} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import {FilePathKind} from '@truenine/plugin-shared' +import {describe, expect, it, vi} from 'vitest' +import {AntigravityOutputPlugin} from './AntigravityOutputPlugin' + +vi.mock('node:fs') +vi.mock('node:os') + +describe('antigravityOutputPlugin', () => { + const plugin = new AntigravityOutputPlugin() + const projectBasePath = '/user/project' + const projectPath = 'my-project' + const homeDir = '/home/user' + + vi.mocked(os.homedir).mockReturnValue(homeDir) + + const projectDir: RelativePath = { + pathKind: FilePathKind.Relative, + path: projectPath, + basePath: projectBasePath, + getDirectoryName: () => 'my-project', + getAbsolutePath: () => `${projectBasePath}/${projectPath}` + } + + const mockSkills: any[] = [ + { + dir: { + pathKind: FilePathKind.Relative, + path: 'my-skill', + basePath: projectBasePath, + getDirectoryName: () => 'my-skill', + getAbsolutePath: () => `${projectBasePath}/my-skill` + }, + content: '# My Skill', + yamlFrontMatter: {name: 'custom-skill'}, + resources: [ + {relativePath: 'res.txt', content: 'resource content'} + ], + childDocs: [ + { + dir: { + pathKind: FilePathKind.Relative, + path: 'doc.mdx', + basePath: projectBasePath, + getDirectoryName: () => 'doc', + getAbsolutePath: () => `${projectBasePath}/doc.mdx` + }, + content: 'doc content' + } + ] + } + ] + + const mockFastCommands: any[] = [ + { + commandName: 'cmd1', + series: 'custom', + dir: { + pathKind: FilePathKind.Relative, + path: 'cmd1.md', + basePath: projectBasePath, + getDirectoryName: () => 'cmd1', + getAbsolutePath: () => `${projectBasePath}/cmd1.md` + }, + content: '# Command 1', + yamlFrontMatter: {description: 'A description', other: 'ignore'} + }, + { + commandName: 'cmd2', + series: 'custom', + dir: { + pathKind: FilePathKind.Relative, + path: 'cmd2.md', + basePath: projectBasePath, + getDirectoryName: () => 'cmd2', + getAbsolutePath: () => `${projectBasePath}/cmd2.md` + }, + content: '# Command 2', + rawMdxContent: '---\ntitle: original\n---\n# Command 2 Raw', + yamlFrontMatter: {description: 'Desc 2'} + } + ] + + const mockInputContext: any = { + globalMemory: null, + workspace: { + projects: [ + { + name: 'p1', + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: null + } + ] + }, + skills: mockSkills, + fastCommands: mockFastCommands + } + + const mockContext: any = { + collectedInputContext: mockInputContext, + tools: { + readProjectFile: vi.fn() + }, + config: { + plugins: [] + }, + dryRun: false + } + + it('should register output directories for clean (project local)', async () => { + const ctx = { + collectedInputContext: { + workspace: { + projects: [ + { + dirFromWorkspacePath: projectDir + } + ] + } + } + } as any + + const results = await plugin.registerProjectOutputDirs(ctx) + expect(results).toHaveLength(2) // Should still register local project directories for cleanup + const paths = results.map(r => r.path.replaceAll('\\', '/')) + expect(paths.some(p => p.includes('.agent/skills'))).toBe(true) + expect(paths.some(p => p.includes('.agent/workflows'))).toBe(true) + }) + + it('should register output files for skills (global)', async () => { + const ctx = { + collectedInputContext: { + workspace: { + projects: [] // even with no projects, global files should be registered if skills exist + }, + skills: mockSkills + } + } as any + + const results = await plugin.registerProjectOutputFiles(ctx) + expect(results).toHaveLength(3) + const paths = new Set(results.map(r => r.path.replaceAll('\\', '/'))) + expect(paths.has('SKILL.md')).toBe(true) // r.path is now the relative filename + expect(paths.has('doc.md')).toBe(true) + expect(paths.has('res.txt')).toBe(true) + + const globalPathPart = '.gemini/antigravity/skills' // Check if base paths are global + const basePaths = results.map(r => r.basePath.replaceAll('\\', '/')) + expect(basePaths.every(p => p.includes(globalPathPart))).toBe(true) + }) + + it('should write skills correctly to global dir', async () => { + await plugin.writeProjectOutputs(mockContext) + + const expectedSkillPath = '.gemini/antigravity/skills/custom-skill/SKILL.md' // Global path: /home/user/.gemini/antigravity/skills/custom-skill/SKILL.md // Check for global path write + + const skillCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => + String(call[0]).replaceAll('\\', '/').includes(expectedSkillPath)) + + expect(skillCall).toBeDefined() + expect(skillCall![1]).toContain('# My Skill') + + const resCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => + String(call[0]).replaceAll('\\', '/').includes('.gemini/antigravity/skills/custom-skill/res.txt')) + expect(resCall).toBeDefined() + expect(resCall![1]).toBe('resource content') + + const docCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => + String(call[0]).replaceAll('\\', '/').includes('.gemini/antigravity/skills/custom-skill/doc.md')) + expect(docCall).toBeDefined() + expect(docCall![1]).toBe('doc content') + }) + + it('should write workflows (fast commands) correctly to global dir', async () => { + await plugin.writeProjectOutputs(mockContext) + + const expectedWorkflowPath = '.gemini/antigravity/workflows' // Expected: /home/user/.gemini/antigravity/workflows/custom-cmd1.md + + const cmd1Call = vi.mocked(fs.writeFileSync).mock.calls.find(call => { + const normalizedPath = String(call[0]).replaceAll('\\', '/') + return normalizedPath.includes(expectedWorkflowPath) && normalizedPath.includes('custom-cmd1.md') + }) + expect(cmd1Call).toBeDefined() + const cmd1Content = cmd1Call![1] as string + expect(cmd1Content).toContain('description: A description') + + const cmd2Call = vi.mocked(fs.writeFileSync).mock.calls.find(call => { + const normalizedPath = String(call[0]).replaceAll('\\', '/') + return normalizedPath.includes(expectedWorkflowPath) && normalizedPath.includes('custom-cmd2.md') + }) + expect(cmd2Call).toBeDefined() + const cmd2Content = cmd2Call![1] as string + expect(cmd2Content).toContain('# Command 2 Raw') + }) + + it('should not write files in dry run mode', async () => { + const dryRunContext = {...mockContext, dryRun: true} + vi.mocked(fs.writeFileSync).mockClear() + + const results = await plugin.writeProjectOutputs(dryRunContext) + + expect(fs.writeFileSync).not.toHaveBeenCalled() + expect(results.files.length).toBeGreaterThan(0) + expect(results.files.every(f => f.success)).toBe(true) + }) + + describe('mcp config merging', () => { + const skillWithMcp: any = { + dir: { + pathKind: FilePathKind.Relative, + path: 'mcp-skill', + basePath: projectBasePath, + getDirectoryName: () => 'mcp-skill', + getAbsolutePath: () => `${projectBasePath}/mcp-skill` + }, + content: '# MCP Skill', + yamlFrontMatter: {name: 'mcp-skill'}, + mcpConfig: { + type: 'SkillMcpConfig', + mcpServers: { + context7: {command: 'npx', args: ['-y', '@upstash/context7-mcp']}, + deepwiki: {url: 'https://mcp.deepwiki.com/mcp'} + }, + rawContent: '{"mcpServers":{}}' + } + } + + const skillWithoutMcp: any = { + dir: { + pathKind: FilePathKind.Relative, + path: 'normal-skill', + basePath: projectBasePath, + getDirectoryName: () => 'normal-skill', + getAbsolutePath: () => `${projectBasePath}/normal-skill` + }, + content: '# Normal Skill', + yamlFrontMatter: {name: 'normal-skill'} + } + + it('should register mcp_config.json when any skill has MCP config', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: []}, + skills: [skillWithMcp] + } + } as any + + const results = await plugin.registerProjectOutputFiles(ctx) + const mcpFile = results.find(r => r.path === 'mcp_config.json') + + expect(mcpFile).toBeDefined() + expect(mcpFile!.basePath.replaceAll('\\', '/')).toContain('.gemini/antigravity') + }) + + it('should NOT register mcp_config.json when no skill has MCP config', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: []}, + skills: [skillWithoutMcp] + } + } as any + + const results = await plugin.registerProjectOutputFiles(ctx) + const mcpFile = results.find(r => r.path === 'mcp_config.json') + + expect(mcpFile).toBeUndefined() + }) + + it('should write merged MCP config with correct format', async () => { + vi.mocked(fs.writeFileSync).mockClear() + + const ctx = { + collectedInputContext: { + globalMemory: null, + workspace: {projects: []}, + skills: [skillWithMcp], + fastCommands: null + }, + config: {plugins: []}, + dryRun: false + } as any + + await plugin.writeProjectOutputs(ctx) + + const mcpCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => + String(call[0]).replaceAll('\\', '/').includes('mcp_config.json')) + + expect(mcpCall).toBeDefined() + const content = JSON.parse(mcpCall![1] as string) + expect(content.mcpServers).toBeDefined() + expect(content.mcpServers.context7).toBeDefined() + expect(content.mcpServers.deepwiki).toBeDefined() + expect(content.mcpServers.deepwiki.serverUrl).toBe('https://mcp.deepwiki.com/mcp') + expect(content.mcpServers.deepwiki.url).toBeUndefined() + }) + + it('should skip writing mcp_config.json when no skill has MCP config', async () => { + vi.mocked(fs.writeFileSync).mockClear() + + const ctx = { + collectedInputContext: { + globalMemory: null, + workspace: {projects: []}, + skills: [skillWithoutMcp], + fastCommands: null + }, + config: {plugins: []}, + dryRun: false + } as any + + await plugin.writeProjectOutputs(ctx) + + const mcpCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => + String(call[0]).replaceAll('\\', '/').includes('mcp_config.json')) + + expect(mcpCall).toBeUndefined() + }) + + it('should not write mcp_config.json in dry-run mode', async () => { + vi.mocked(fs.writeFileSync).mockClear() + + const ctx = { + collectedInputContext: { + globalMemory: null, + workspace: {projects: []}, + skills: [skillWithMcp], + fastCommands: null + }, + config: {plugins: []}, + dryRun: true + } as any + + const results = await plugin.writeProjectOutputs(ctx) + + expect(fs.writeFileSync).not.toHaveBeenCalled() + const mcpResult = results.files.find(f => f.path.path === 'mcp_config.json') + expect(mcpResult).toBeDefined() + expect(mcpResult!.success).toBe(true) + }) + }) +}) diff --git a/cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.ts b/cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.ts new file mode 100644 index 00000000..1f274575 --- /dev/null +++ b/cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.ts @@ -0,0 +1,216 @@ +import type { + FastCommandPrompt, + OutputPluginContext, + OutputWriteContext, + SkillPrompt, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import * as os from 'node:os' +import * as path from 'node:path' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {PLUGIN_NAMES} from '@truenine/plugin-shared' + +const GLOBAL_CONFIG_DIR = '.agent' +const GLOBAL_GEMINI_DIR = '.gemini' +const ANTIGRAVITY_DIR = 'antigravity' +const SKILLS_SUBDIR = 'skills' +const WORKFLOWS_SUBDIR = 'workflows' +const MCP_CONFIG_FILE = 'mcp_config.json' +const CLEANUP_SUBDIRS = [SKILLS_SUBDIR, WORKFLOWS_SUBDIR] as const + +export class AntigravityOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('AntigravityOutputPlugin', { + globalConfigDir: GLOBAL_CONFIG_DIR, + outputFileName: '', + dependsOn: [PLUGIN_NAMES.GeminiCLIOutput] + }) + + this.registerCleanEffect('mcp-config-cleanup', async ctx => { + const mcpPath = path.join(this.getAntigravityDir(), MCP_CONFIG_FILE) + const content = JSON.stringify({mcpServers: {}}, null, 2) + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'mcpConfigCleanup', path: mcpPath}) + return {success: true, description: 'Would reset mcp_config.json'} + } + const result = await this.writeFile(ctx, mcpPath, content, 'mcpConfigCleanup') + if (result.success) return {success: true, description: 'Reset mcp_config.json'} + return {success: false, description: 'Failed', error: result.error ?? new Error('Cleanup failed')} + }) + } + + private getAntigravityDir(): string { + return path.join(os.homedir(), GLOBAL_GEMINI_DIR, ANTIGRAVITY_DIR) + } + + async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + const results: RelativePath[] = [] + + for (const project of projects) { + if (project.dirFromWorkspacePath == null) continue + for (const subdir of CLEANUP_SUBDIRS) { + results.push(this.createRelativePath( + path.join(project.dirFromWorkspacePath.path, GLOBAL_CONFIG_DIR, subdir), + project.dirFromWorkspacePath.basePath, + () => subdir + )) + } + } + return results + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const {skills, fastCommands} = ctx.collectedInputContext + const baseDir = this.getAntigravityDir() + const results: RelativePath[] = [] + + if (skills != null) { + for (const skill of skills) { + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const skillDir = path.join(baseDir, SKILLS_SUBDIR, skillName) + + results.push(this.createRelativePath('SKILL.md', skillDir, () => skillName)) + + if (skill.childDocs != null) { + for (const refDoc of skill.childDocs) { + results.push(this.createRelativePath( + refDoc.dir.path.replace(/\.mdx$/, '.md'), + skillDir, + () => skillName + )) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) results.push(this.createRelativePath(resource.relativePath, skillDir, () => skillName)) + } + } + } + + if (skills?.some(s => s.mcpConfig != null)) results.push(this.createRelativePath(MCP_CONFIG_FILE, baseDir, () => ANTIGRAVITY_DIR)) + + if (fastCommands == null) return results + + const transformOptions = this.getTransformOptionsFromContext(ctx) + const workflowsDir = path.join(baseDir, WORKFLOWS_SUBDIR) + for (const cmd of fastCommands) { + results.push(this.createRelativePath( + this.transformFastCommandName(cmd, transformOptions), + workflowsDir, + () => WORKFLOWS_SUBDIR + )) + } + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {fastCommands, skills} = ctx.collectedInputContext + if ((fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0) return true + this.log.trace({action: 'skip', reason: 'noOutputs'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const {fastCommands, skills} = ctx.collectedInputContext + const fileResults: WriteResult[] = [] + const baseDir = this.getAntigravityDir() + + if (fastCommands != null) { + const workflowsDir = path.join(baseDir, WORKFLOWS_SUBDIR) + for (const cmd of fastCommands) fileResults.push(await this.writeFastCommand(ctx, workflowsDir, cmd)) + } + + if (skills != null) { + const skillsDir = path.join(baseDir, SKILLS_SUBDIR) + for (const skill of skills) fileResults.push(...await this.writeSkill(ctx, skillsDir, skill)) + const mcpResult = await this.writeGlobalMcpConfig(ctx, baseDir, skills) + if (mcpResult != null) fileResults.push(mcpResult) + } + + this.log.info({action: 'write', message: `Synced ${fileResults.length} files`, globalDir: baseDir}) + return {files: fileResults, dirs: []} + } + + private async writeGlobalMcpConfig( + ctx: OutputWriteContext, + baseDir: string, + skills: readonly SkillPrompt[] + ): Promise { + const mergedServers: Record = {} + + for (const skill of skills) { + if (skill.mcpConfig == null) continue + for (const [name, config] of Object.entries(skill.mcpConfig.mcpServers)) { + mergedServers[name] = this.transformMcpConfig(config as unknown as Record) + } + } + + if (Object.keys(mergedServers).length === 0) return null + + const fullPath = path.join(baseDir, MCP_CONFIG_FILE) + const content = JSON.stringify({mcpServers: mergedServers}, null, 2) + return this.writeFile(ctx, fullPath, content, 'globalMcpConfig') + } + + private transformMcpConfig(config: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(config)) { + if (key === 'url') result['serverUrl'] = value + else if (key === 'type' || key === 'enabled' || key === 'autoApprove') continue + else result[key] = value + } + return result + } + + private async writeFastCommand( + ctx: OutputWriteContext, + targetDir: string, + cmd: FastCommandPrompt + ): Promise { + const transformOptions = this.getTransformOptionsFromContext(ctx) + const fileName = this.transformFastCommandName(cmd, transformOptions) + const fullPath = path.join(targetDir, fileName) + + const filteredFm: {description?: string} = typeof cmd.yamlFrontMatter?.description === 'string' + ? {description: cmd.yamlFrontMatter.description} + : {} + + let content: string + if (cmd.rawMdxContent != null) { + const body = cmd.rawMdxContent.replace(/^---\n[\s\S]*?\n---\n/, '') + content = this.buildMarkdownContentWithRaw(body, filteredFm, cmd.rawFrontMatter) + } else content = this.buildMarkdownContentWithRaw(cmd.content, filteredFm, cmd.rawFrontMatter) + + return this.writeFile(ctx, fullPath, content, 'fastCommand') + } + + private async writeSkill( + ctx: OutputWriteContext, + targetBaseDir: string, + skill: SkillPrompt + ): Promise { + const results: WriteResult[] = [] + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const skillDir = path.join(targetBaseDir, skillName) + const skillPath = path.join(skillDir, 'SKILL.md') + + const content = this.buildMarkdownContentWithRaw(skill.content as string, skill.yamlFrontMatter, skill.rawFrontMatter) + results.push(await this.writeFile(ctx, skillPath, content, 'skill')) + + if (skill.childDocs != null) { + for (const refDoc of skill.childDocs) { + const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') + results.push(await this.writeFile(ctx, path.join(skillDir, fileName), refDoc.content as string, 'skillRefDoc')) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) results.push(await this.writeFile(ctx, path.join(skillDir, resource.relativePath), resource.content, 'skillResource')) + } + + return results + } +} diff --git a/cli/src/plugins/plugin-antigravity/index.ts b/cli/src/plugins/plugin-antigravity/index.ts new file mode 100644 index 00000000..784b1336 --- /dev/null +++ b/cli/src/plugins/plugin-antigravity/index.ts @@ -0,0 +1,3 @@ +export { + AntigravityOutputPlugin +} from './AntigravityOutputPlugin' diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts new file mode 100644 index 00000000..3d20ad39 --- /dev/null +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts @@ -0,0 +1,214 @@ +import type {OutputPluginContext} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' + +class TestableClaudeCodeCLIOutputPlugin extends ClaudeCodeCLIOutputPlugin { + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } +} + +function createMockContext( + tempDir: string, + rules: unknown[], + projects: unknown[] +): OutputPluginContext { + return { + collectedInputContext: { + workspace: { + projects: projects as never, + directory: { + pathKind: 1, + path: tempDir, + basePath: tempDir, + getDirectoryName: () => 'workspace', + getAbsolutePath: () => tempDir + } + }, + ideConfigFiles: [], + rules: rules as never, + fastCommands: [], + skills: [], + globalMemory: void 0, + aiAgentIgnoreConfigFiles: [], + subAgents: [] + }, + logger: { + debug: vi.fn(), + trace: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + } as never, + fs, + path, + glob: vi.fn() as never + } +} + +describe('claudeCodeCLIOutputPlugin - projectConfig filtering', () => { + let tempDir: string, + plugin: TestableClaudeCodeCLIOutputPlugin + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-proj-config-test-')) + plugin = new TestableClaudeCodeCLIOutputPlugin() + plugin.setMockHomeDir(tempDir) + }) + + afterEach(() => { + try { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch {} + }) + + describe('registerProjectOutputFiles', () => { + it('should include all project rules when no projectConfig', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [createMockProject('proj1', tempDir, 'proj1')] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.md') + expect(fileNames).toContain('rule-test-rule2.md') + }) + + it('should filter rules by include in projectConfig', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.md') + expect(fileNames).not.toContain('rule-test-rule2.md') + }) + + it('should filter rules by includeSeries excluding non-matching series', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).not.toContain('rule-test-rule1.md') + expect(fileNames).toContain('rule-test-rule2.md') + }) + + it('should include rules without seriName regardless of include filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', void 0, 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.md') + expect(fileNames).not.toContain('rule-test-rule2.md') + }) + + it('should filter independently for each project', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}), + createMockProject('proj2', tempDir, 'proj2', {rules: {includeSeries: ['vue']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = results.map(r => ({ + path: r.path, + fileName: r.path.split(/[/\\]/).pop() + })) + + expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule1.md')).toBe(true) + expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule2.md')).toBe(false) + expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule2.md')).toBe(true) + expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule1.md')).toBe(false) + }) + + it('should return empty when include matches nothing', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const ruleFiles = results.filter(r => r.path.includes('rule-')) + + expect(ruleFiles).toHaveLength(0) + }) + }) + + describe('registerProjectOutputDirs', () => { + it('should not register rules dir when all rules filtered out', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputDirs(ctx) + const rulesDirs = results.filter(r => r.path.includes('rules')) + + expect(rulesDirs).toHaveLength(0) + }) + + it('should register rules dir when rules match filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputDirs(ctx) + const rulesDirs = results.filter(r => r.path.includes('rules')) + + expect(rulesDirs.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts new file mode 100644 index 00000000..bcc4d54c --- /dev/null +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts @@ -0,0 +1,161 @@ +import type {CollectedInputContext, OutputPluginContext, Project, RelativePath, RootPath, RulePrompt} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' +import * as fc from 'fast-check' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { + return {pathKind: FilePathKind.Relative, path: pathStr, basePath, getDirectoryName: () => pathStr, getAbsolutePath: () => path.join(basePath, pathStr)} +} + +class TestablePlugin extends ClaudeCodeCLIOutputPlugin { + private mockHomeDir: string | null = null + public setMockHomeDir(dir: string | null): void { this.mockHomeDir = dir } + protected override getHomeDir(): string { return this.mockHomeDir ?? super.getHomeDir() } + public testBuildRuleFileName(rule: RulePrompt): string { return (this as any).buildRuleFileName(rule) } + public testBuildRuleContent(rule: RulePrompt): string { return (this as any).buildRuleContent(rule) } +} + +function createMockRulePrompt(opts: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { + const content = opts.content ?? '# Rule body' + return {type: PromptKind.Rule, content, length: content.length, filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', ''), markdownContents: [], yamlFrontMatter: {description: 'ignored', globs: opts.globs}, series: opts.series, ruleName: opts.ruleName, globs: opts.globs, scope: opts.scope ?? 'global'} as RulePrompt +} + +const seriesGen = fc.stringMatching(/^[a-z0-9]{1,5}$/) +const ruleNameGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,14}$/) +const globGen = fc.stringMatching(/^[a-z*/.]{1,30}$/).filter(s => s.length > 0) +const globsGen = fc.array(globGen, {minLength: 1, maxLength: 5}) +const contentGen = fc.string({minLength: 1, maxLength: 200}).filter(s => s.trim().length > 0) + +describe('claudeCodeCLIOutputPlugin property tests', () => { + let tempDir: string, plugin: TestablePlugin, mockContext: OutputPluginContext + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-prop-')) + plugin = new TestablePlugin() + plugin.setMockHomeDir(tempDir) + mockContext = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + globalMemory: {type: PromptKind.GlobalMemory, content: 'mem', filePathKind: FilePathKind.Absolute, dir: createMockRelativePath('.', tempDir), markdownContents: []}, + fastCommands: [], + subAgents: [], + skills: [] + } as unknown as CollectedInputContext, + logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, + fs, + path, + glob: {} as any + } + }, 30000) + + afterEach(() => { + try { fs.rmSync(tempDir, {recursive: true, force: true}) } + catch {} + }) + + describe('rule file name format', () => { + it('should always produce rule-{series}-{ruleName}.md', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, async (series, ruleName) => { + const rule = createMockRulePrompt({series, ruleName, globs: []}) + const fileName = plugin.testBuildRuleFileName(rule) + expect(fileName).toBe(`rule-${series}-${ruleName}.md`) + expect(fileName).toMatch(/^rule-.[^-\n\r\u2028\u2029]*-.+\.md$/) + }), {numRuns: 100}) + }) + }) + + describe('rule content format constraints', () => { + it('should never contain globs field in frontmatter', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, content}) + const output = plugin.testBuildRuleContent(rule) + expect(output).not.toMatch(/^globs:/m) + }), {numRuns: 100}) + }) + + it('should use paths field when globs are present', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, content}) + const output = plugin.testBuildRuleContent(rule) + expect(output).toContain('paths:') + }), {numRuns: 100}) + }) + + it('should wrap frontmatter in --- delimiters when globs exist', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, content}) + const output = plugin.testBuildRuleContent(rule) + const lines = output.split('\n') + expect(lines[0]).toBe('---') + expect(lines.indexOf('---', 1)).toBeGreaterThan(0) + }), {numRuns: 100}) + }) + + it('should have no frontmatter when globs are empty', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, contentGen, async (series, ruleName, content) => { + const rule = createMockRulePrompt({series, ruleName, globs: [], content}) + const output = plugin.testBuildRuleContent(rule) + expect(output).not.toContain('---') + expect(output).toBe(content) + }), {numRuns: 100}) + }) + + it('should preserve rule body content after frontmatter', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, content}) + const output = plugin.testBuildRuleContent(rule) + expect(output).toContain(content) + }), {numRuns: 100}) + }) + + it('should list each glob as a YAML array item under paths', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, content}) + const output = plugin.testBuildRuleContent(rule) + for (const g of globs) expect(output).toContain(`- "${g}"`) + }), {numRuns: 100}) + }) + }) + + describe('write output format verification', () => { + it('should write global rule files with correct format to ~/.claude/rules/', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, scope: 'global', content}) + const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, rules: [rule]}} as any + await plugin.writeGlobalOutputs(ctx) + const filePath = path.join(tempDir, '.claude', 'rules', `rule-${series}-${ruleName}.md`) + expect(fs.existsSync(filePath)).toBe(true) + const written = fs.readFileSync(filePath, 'utf8') + expect(written).toContain('paths:') + expect(written).not.toMatch(/^globs:/m) + expect(written).toContain(content) + for (const g of globs) expect(written).toContain(`- "${g}"`) + }), {numRuns: 30}) + }) + + it('should write project rule files to {project}/.claude/rules/', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const mockProject: Project = { + name: 'proj', + dirFromWorkspacePath: createMockRelativePath('proj', tempDir), + rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Root, dir: createMockRelativePath('.', tempDir) as unknown as RootPath, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}}, + childMemoryPrompts: [], + sourceFiles: [] + } + const rule = createMockRulePrompt({series, ruleName, globs, scope: 'project', content}) + const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, rules: [rule]}} as any + await plugin.writeProjectOutputs(ctx) + const filePath = path.join(tempDir, 'proj', '.claude', 'rules', `rule-${series}-${ruleName}.md`) + expect(fs.existsSync(filePath)).toBe(true) + const written = fs.readFileSync(filePath, 'utf8') + expect(written).toContain('paths:') + expect(written).toContain(content) + for (const g of globs) expect(written).toContain(`- "${g}"`) + }), {numRuns: 30}) + }) + }) +}) diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts new file mode 100644 index 00000000..cfd70e05 --- /dev/null +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts @@ -0,0 +1,504 @@ +import type {CollectedInputContext, FastCommandPrompt, OutputPluginContext, Project, RelativePath, RootPath, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { // Helper to create mock RelativePath + return { + pathKind: FilePathKind.Relative, + path: pathStr, + basePath, + getDirectoryName: () => pathStr, + getAbsolutePath: () => path.join(basePath, pathStr) + } +} + +class TestableClaudeCodeCLIOutputPlugin extends ClaudeCodeCLIOutputPlugin { // Testable subclass to mock home dir + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } + + public testBuildRuleFileName(rule: RulePrompt): string { + return (this as any).buildRuleFileName(rule) + } + + public testBuildRuleContent(rule: RulePrompt): string { + return (this as any).buildRuleContent(rule) + } +} + +function createMockRulePrompt(options: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { + const content = options.content ?? '# Rule body' + return { + type: PromptKind.Rule, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', ''), + markdownContents: [], + yamlFrontMatter: {description: 'ignored', globs: options.globs}, + series: options.series, + ruleName: options.ruleName, + globs: options.globs, + scope: options.scope ?? 'global' + } as RulePrompt +} + +describe('claudeCodeCLIOutputPlugin', () => { + let tempDir: string, + plugin: TestableClaudeCodeCLIOutputPlugin, + mockContext: OutputPluginContext + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-')) + plugin = new TestableClaudeCodeCLIOutputPlugin() + plugin.setMockHomeDir(tempDir) + + mockContext = { + collectedInputContext: { + workspace: { + projects: [], + directory: createMockRelativePath('.', tempDir) + }, + globalMemory: { + type: PromptKind.GlobalMemory, + content: 'Global Memory Content', + filePathKind: FilePathKind.Absolute, + dir: createMockRelativePath('.', tempDir), + markdownContents: [] + }, + fastCommands: [], + subAgents: [], + skills: [] + } as unknown as CollectedInputContext, + logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, + fs, + path, + glob: {} as any + } + }, 30000) + + afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch { + } // ignore cleanup errors + } + }) + + describe('registerGlobalOutputDirs', () => { + it('should register commands, agents, and skills subdirectories in .claude', async () => { + const dirs = await plugin.registerGlobalOutputDirs(mockContext) + + const dirPaths = dirs.map(d => d.path) + expect(dirPaths).toContain('commands') + expect(dirPaths).toContain('agents') + expect(dirPaths).toContain('skills') + + const expectedBasePath = path.join(tempDir, '.claude') + dirs.forEach(d => expect(d.basePath).toBe(expectedBasePath)) + }) + }) + + describe('registerProjectOutputDirs', () => { + it('should register project cleanup directories', async () => { + const mockProject: Project = { + name: 'test-project', + dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), + rootMemoryPrompt: { + type: PromptKind.ProjectRootMemory, + content: 'content', + filePathKind: FilePathKind.Root, + dir: createMockRelativePath('.', tempDir) as unknown as RootPath, + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} + }, + childMemoryPrompts: [], + sourceFiles: [] + } + + const ctxWithProject = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + workspace: { + ...mockContext.collectedInputContext.workspace, + projects: [mockProject] + } + } + } + + const dirs = await plugin.registerProjectOutputDirs(ctxWithProject) + const dirPaths = dirs.map(d => d.path) // (Or possibly more if logic changed, but based on code, it loops subdirs) // Expect 3 dirs: .claude/commands, .claude/agents, .claude/skills + + expect(dirPaths.some(p => p.includes(path.join('.claude', 'commands')))).toBe(true) + expect(dirPaths.some(p => p.includes(path.join('.claude', 'agents')))).toBe(true) + expect(dirPaths.some(p => p.includes(path.join('.claude', 'skills')))).toBe(true) + }) + }) + + describe('registerGlobalOutputFiles', () => { + it('should register CLAUDE.md in global config dir', async () => { + const files = await plugin.registerGlobalOutputFiles(mockContext) + const outputFile = files.find(f => f.path === 'CLAUDE.md') + expect(outputFile).toBeDefined() + expect(outputFile?.basePath).toBe(path.join(tempDir, '.claude')) + }) + + it('should register fast commands in commands subdirectory', async () => { + const mockCmd: FastCommandPrompt = { + type: PromptKind.FastCommand, + commandName: 'test-cmd', + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('test-cmd', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, description: 'desc'} + } + + const ctxWithCmd = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + fastCommands: [mockCmd] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithCmd) + const cmdFile = files.find(f => f.path.includes('test-cmd.md')) + + expect(cmdFile).toBeDefined() + expect(cmdFile?.path).toContain('commands') + expect(cmdFile?.basePath).toBe(path.join(tempDir, '.claude')) + }) + + it('should register sub agents in agents subdirectory', async () => { + const mockAgent: SubAgentPrompt = { + type: PromptKind.SubAgent, + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('test-agent.md', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'agent', description: 'desc'} + } + + const ctxWithAgent = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + subAgents: [mockAgent] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) + const agentFile = files.find(f => f.path.includes('test-agent.md')) + + expect(agentFile).toBeDefined() + expect(agentFile?.path).toContain('agents') + expect(agentFile?.basePath).toBe(path.join(tempDir, '.claude')) + }) + + it('should strip .mdx suffix from sub agent path and use .md', async () => { + const mockAgent: SubAgentPrompt = { + type: PromptKind.SubAgent, + content: 'agent content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('code-review.cn.mdx', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'desc'} + } + + const ctxWithAgent = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + subAgents: [mockAgent] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) + const agentFile = files.find(f => f.path.includes('agents')) + + expect(agentFile).toBeDefined() + expect(agentFile?.path).toContain('code-review.cn.md') + expect(agentFile?.path).not.toContain('.mdx') + }) + + it('should register skills in skills subdirectory', 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'} + } + + const ctxWithSkill = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + skills: [mockSkill] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) + const skillFile = files.find(f => f.path.includes('SKILL.md')) + + expect(skillFile).toBeDefined() + expect(skillFile?.path).toContain('skills') + expect(skillFile?.basePath).toBe(path.join(tempDir, '.claude')) + }) + }) + + describe('registerProjectOutputFiles', () => { + it('should only register project CLAUDE.md files', async () => { + const mockProject: Project = { + name: 'test-project', + dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), + rootMemoryPrompt: { + type: PromptKind.ProjectRootMemory, + content: 'content', + filePathKind: FilePathKind.Root, + dir: createMockRelativePath('.', tempDir) as unknown as RootPath, + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} + }, + childMemoryPrompts: [], + sourceFiles: [] + } + + const ctxWithProject = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + workspace: { + ...mockContext.collectedInputContext.workspace, + projects: [mockProject] + } + } + } + + const files = await plugin.registerProjectOutputFiles(ctxWithProject) + + expect(files).toHaveLength(1) + expect(files[0].path).toBe(path.join('project-a', 'CLAUDE.md')) + expect(files[0].basePath).toBe(tempDir) + }) + }) + + describe('writeGlobalOutputs', () => { + it('should write sub agent file with .md extension when source has .mdx', async () => { + const mockAgent: SubAgentPrompt = { + type: PromptKind.SubAgent, + content: '# Code Review Agent', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('reviewer.cn.mdx', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'reviewer', description: 'desc'} + } + + const writeCtx = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + subAgents: [mockAgent] + } + } + + const results = await plugin.writeGlobalOutputs(writeCtx) + const agentResult = results.files.find(f => f.path.path === 'reviewer.cn.md') + + expect(agentResult).toBeDefined() + expect(agentResult?.success).toBe(true) + + const writtenPath = path.join(tempDir, '.claude', 'agents', 'reviewer.cn.md') + expect(fs.existsSync(writtenPath)).toBe(true) + expect(fs.existsSync(path.join(tempDir, '.claude', 'agents', 'reviewer.cn.mdx'))).toBe(false) + expect(fs.existsSync(path.join(tempDir, '.claude', 'agents', 'reviewer.cn.mdx.md'))).toBe(false) + }) + }) + + describe('buildRuleFileName', () => { + it('should produce rule-{series}-{ruleName}.md', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'naming', globs: []}) + expect(plugin.testBuildRuleFileName(rule)).toBe('rule-01-naming.md') + }) + }) + + describe('buildRuleContent', () => { + it('should return plain content when globs is empty', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: [], content: '# No globs'}) + expect(plugin.testBuildRuleContent(rule)).toBe('# No globs') + }) + + it('should use paths field (not globs) in YAML frontmatter per Claude Code docs', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], content: '# TS rule'}) + const content = plugin.testBuildRuleContent(rule) + expect(content).toContain('paths:') + expect(content).not.toMatch(/^globs:/m) + }) + + it('should output paths as YAML array items', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts', '**/*.tsx'], content: '# Body'}) + const content = plugin.testBuildRuleContent(rule) + expect(content).toContain('- "**/*.ts"') + expect(content).toContain('- "**/*.tsx"') + }) + + it('should double-quote paths that do not start with *', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['src/components/*.tsx', 'lib/utils.ts'], content: '# Body'}) + const content = plugin.testBuildRuleContent(rule) + expect(content).toContain('- "src/components/*.tsx"') + expect(content).toContain('- "lib/utils.ts"') + }) + + it('should preserve rule body after frontmatter', () => { + const body = '# My Rule\n\nSome content.' + const rule = createMockRulePrompt({series: '01', ruleName: 'x', globs: ['*.ts'], content: body}) + const content = plugin.testBuildRuleContent(rule) + expect(content).toContain(body) + }) + + it('should wrap content in valid YAML frontmatter delimiters', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'x', globs: ['*.ts'], content: '# Body'}) + const content = plugin.testBuildRuleContent(rule) + const lines = content.split('\n') + expect(lines[0]).toBe('---') + expect(lines.indexOf('---', 1)).toBeGreaterThan(0) + }) + }) + + describe('rules registration', () => { + it('should register rules subdir in global output dirs when global rules exist', async () => { + const ctx = { + ...mockContext, + collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} + } + const dirs = await plugin.registerGlobalOutputDirs(ctx) + expect(dirs.map(d => d.path)).toContain('rules') + }) + + it('should not register rules subdir when no global rules', async () => { + const dirs = await plugin.registerGlobalOutputDirs(mockContext) + expect(dirs.map(d => d.path)).not.toContain('rules') + }) + + it('should register global rule files in ~/.claude/rules/', async () => { + const ctx = { + ...mockContext, + collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} + } + const files = await plugin.registerGlobalOutputFiles(ctx) + const ruleFile = files.find(f => f.path === 'rule-01-ts.md') + expect(ruleFile).toBeDefined() + expect(ruleFile?.basePath).toBe(path.join(tempDir, '.claude', 'rules')) + }) + + it('should not register project rules as global files', async () => { + const ctx = { + ...mockContext, + collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'project'})]} + } + const files = await plugin.registerGlobalOutputFiles(ctx) + expect(files.find(f => f.path.includes('rule-'))).toBeUndefined() + }) + }) + + describe('canWrite with rules', () => { + it('should return true when rules exist even without other content', async () => { + const ctx = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + globalMemory: void 0, + rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: []})] + } + } + expect(await plugin.canWrite(ctx as any)).toBe(true) + }) + }) + + describe('writeGlobalOutputs with rules', () => { + it('should write global rule file to ~/.claude/rules/', async () => { + const ctx = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global', content: '# TS rule'})] + } + } + const results = await plugin.writeGlobalOutputs(ctx as any) + const ruleResult = results.files.find(f => f.path.path === 'rule-01-ts.md') + expect(ruleResult?.success).toBe(true) + + const filePath = path.join(tempDir, '.claude', 'rules', 'rule-01-ts.md') + expect(fs.existsSync(filePath)).toBe(true) + const content = fs.readFileSync(filePath, 'utf8') + expect(content).toContain('paths:') + expect(content).toContain('# TS rule') + }) + + it('should write rule without frontmatter when globs is empty', async () => { + const ctx = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + rules: [createMockRulePrompt({series: '01', ruleName: 'general', globs: [], scope: 'global', content: '# Always apply'})] + } + } + await plugin.writeGlobalOutputs(ctx as any) + const filePath = path.join(tempDir, '.claude', 'rules', 'rule-01-general.md') + const content = fs.readFileSync(filePath, 'utf8') + expect(content).toBe('# Always apply') + expect(content).not.toContain('---') + }) + }) + + describe('writeProjectOutputs with rules', () => { + it('should write project rule file to {project}/.claude/rules/', async () => { + const mockProject: Project = { + name: 'proj', + dirFromWorkspacePath: createMockRelativePath('proj', tempDir), + rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Root, dir: createMockRelativePath('.', tempDir) as unknown as RootPath, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}}, + childMemoryPrompts: [], + sourceFiles: [] + } + const ctx = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, + rules: [createMockRulePrompt({series: '02', ruleName: 'api', globs: ['src/api/**'], scope: 'project', content: '# API rules'})] + } + } + const results = await plugin.writeProjectOutputs(ctx as any) + expect(results.files.some(f => f.path.path === 'rule-02-api.md' && f.success)).toBe(true) + + const filePath = path.join(tempDir, 'proj', '.claude', 'rules', 'rule-02-api.md') + expect(fs.existsSync(filePath)).toBe(true) + const content = fs.readFileSync(filePath, 'utf8') + expect(content).toContain('paths:') + expect(content).toContain('# API rules') + }) + }) +}) diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts new file mode 100644 index 00000000..52b6de9b --- /dev/null +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts @@ -0,0 +1,135 @@ +import type {OutputPluginContext, OutputWriteContext, RulePrompt, WriteResults} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import * as path from 'node:path' +import {buildMarkdownWithFrontMatter, doubleQuoted} from '@truenine/md-compiler/markdown' +import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' +import {applySubSeriesGlobPrefix, filterRulesByProjectConfig} from '@truenine/plugin-output-shared/utils' + +const PROJECT_MEMORY_FILE = 'CLAUDE.md' +const GLOBAL_CONFIG_DIR = '.claude' +const RULES_SUBDIR = 'rules' +const RULE_FILE_PREFIX = 'rule-' + +/** + * Output plugin for Claude Code CLI. + * + * Outputs rules to `.claude/rules/` directory with frontmatter format. + * + * @see https://github.com/anthropics/claude-code/issues/26868 + * Known bug: Claude Code CLI has issues with `.claude/rules` directory handling. + * This may affect rule loading behavior in certain scenarios. + */ +export class ClaudeCodeCLIOutputPlugin extends BaseCLIOutputPlugin { + constructor() { + super('ClaudeCodeCLIOutputPlugin', { + globalConfigDir: GLOBAL_CONFIG_DIR, + outputFileName: PROJECT_MEMORY_FILE, + toolPreset: 'claudeCode', + supportsFastCommands: true, + supportsSubAgents: true, + supportsSkills: true + }) + } + + private buildRuleFileName(rule: RulePrompt): string { + return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` + } + + private buildRuleContent(rule: RulePrompt): string { + if (rule.globs.length === 0) return rule.content + return buildMarkdownWithFrontMatter({paths: rule.globs.map(doubleQuoted)}, rule.content) + } + + override async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { + const results = await super.registerGlobalOutputDirs(ctx) + const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') + if (globalRules != null && globalRules.length > 0) results.push(this.createRelativePath(RULES_SUBDIR, this.getGlobalConfigDir(), () => RULES_SUBDIR)) + return results + } + + override async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { + const results = await super.registerGlobalOutputFiles(ctx) + const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') + if (globalRules == null || globalRules.length === 0) return results + const rulesDir = path.join(this.getGlobalConfigDir(), RULES_SUBDIR) + for (const rule of globalRules) results.push(this.createRelativePath(this.buildRuleFileName(rule), rulesDir, () => RULES_SUBDIR)) + return results + } + + override async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const results = await super.registerProjectOutputDirs(ctx) + const {rules} = ctx.collectedInputContext + if (rules == null || rules.length === 0) return results + for (const project of ctx.collectedInputContext.workspace.projects) { + if (project.dirFromWorkspacePath == null) continue + const projectRules = applySubSeriesGlobPrefix( + filterRulesByProjectConfig( + rules.filter(r => this.normalizeRuleScope(r) === 'project'), + project.projectConfig + ), + project.projectConfig + ) + if (projectRules.length === 0) continue + const dirPath = path.join(project.dirFromWorkspacePath.path, this.globalConfigDir, RULES_SUBDIR) + results.push(this.createRelativePath(dirPath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) + } + return results + } + + override async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results = await super.registerProjectOutputFiles(ctx) + const {rules} = ctx.collectedInputContext + if (rules == null || rules.length === 0) return results + for (const project of ctx.collectedInputContext.workspace.projects) { + if (project.dirFromWorkspacePath == null) continue + const projectRules = applySubSeriesGlobPrefix( + filterRulesByProjectConfig( + rules.filter(r => this.normalizeRuleScope(r) === 'project'), + project.projectConfig + ), + project.projectConfig + ) + for (const rule of projectRules) { + const filePath = path.join(project.dirFromWorkspacePath.path, this.globalConfigDir, RULES_SUBDIR, this.buildRuleFileName(rule)) + results.push(this.createRelativePath(filePath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) + } + } + return results + } + + override async canWrite(ctx: OutputWriteContext): Promise { + if ((ctx.collectedInputContext.rules?.length ?? 0) > 0) return true + return super.canWrite(ctx) + } + + override async writeGlobalOutputs(ctx: OutputWriteContext): Promise { + const results = await super.writeGlobalOutputs(ctx) + const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') + if (globalRules == null || globalRules.length === 0) return results + const rulesDir = path.join(this.getGlobalConfigDir(), RULES_SUBDIR) + const ruleResults = [] + for (const rule of globalRules) ruleResults.push(await this.writeFile(ctx, path.join(rulesDir, this.buildRuleFileName(rule)), this.buildRuleContent(rule), 'rule')) + return {files: [...results.files, ...ruleResults], dirs: results.dirs} + } + + override async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const results = await super.writeProjectOutputs(ctx) + const {rules} = ctx.collectedInputContext + if (rules == null || rules.length === 0) return results + const ruleResults = [] + for (const project of ctx.collectedInputContext.workspace.projects) { + if (project.dirFromWorkspacePath == null) continue + const projectRules = applySubSeriesGlobPrefix( + filterRulesByProjectConfig( + rules.filter(r => this.normalizeRuleScope(r) === 'project'), + project.projectConfig + ), + project.projectConfig + ) + if (projectRules.length === 0) continue + const rulesDir = path.join(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path, this.globalConfigDir, RULES_SUBDIR) + for (const rule of projectRules) ruleResults.push(await this.writeFile(ctx, path.join(rulesDir, this.buildRuleFileName(rule)), this.buildRuleContent(rule), 'rule')) + } + return {files: [...results.files, ...ruleResults], dirs: results.dirs} + } +} diff --git a/cli/src/plugins/plugin-claude-code-cli/index.ts b/cli/src/plugins/plugin-claude-code-cli/index.ts new file mode 100644 index 00000000..e65d3791 --- /dev/null +++ b/cli/src/plugins/plugin-claude-code-cli/index.ts @@ -0,0 +1,3 @@ +export { + ClaudeCodeCLIOutputPlugin +} from './ClaudeCodeCLIOutputPlugin' diff --git a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts new file mode 100644 index 00000000..e224b5c2 --- /dev/null +++ b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts @@ -0,0 +1,214 @@ +import type {OutputPluginContext} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {CursorOutputPlugin} from './CursorOutputPlugin' + +class TestableCursorOutputPlugin extends CursorOutputPlugin { + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } +} + +function createMockContext( + tempDir: string, + rules: unknown[], + projects: unknown[] +): OutputPluginContext { + return { + collectedInputContext: { + workspace: { + projects: projects as never, + directory: { + pathKind: 1, + path: tempDir, + basePath: tempDir, + getDirectoryName: () => 'workspace', + getAbsolutePath: () => tempDir + } + }, + ideConfigFiles: [], + rules: rules as never, + fastCommands: [], + skills: [], + globalMemory: void 0, + aiAgentIgnoreConfigFiles: [] + }, + logger: { + debug: vi.fn(), + trace: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + } as never, + fs, + path, + glob: vi.fn() as never + } +} + +describe('cursorOutputPlugin - projectConfig filtering', () => { + let tempDir: string, + plugin: TestableCursorOutputPlugin + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-proj-config-test-')) + plugin = new TestableCursorOutputPlugin() + plugin.setMockHomeDir(tempDir) + }) + + afterEach(() => { + try { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch {} + }) + + describe('registerProjectOutputFiles', () => { + it('should include all project rules when no projectConfig', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [createMockProject('proj1', tempDir, 'proj1')] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.mdc') + expect(fileNames).toContain('rule-test-rule2.mdc') + }) + + it('should filter rules by include in projectConfig', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.mdc') + expect(fileNames).not.toContain('rule-test-rule2.mdc') + }) + + it('should filter rules by includeSeries excluding non-matching series', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).not.toContain('rule-test-rule1.mdc') + expect(fileNames).toContain('rule-test-rule2.mdc') + }) + + it('should include rules without seriName regardless of include filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', void 0, 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.mdc') + expect(fileNames).not.toContain('rule-test-rule2.mdc') + }) + + it('should filter independently for each project', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}), + createMockProject('proj2', tempDir, 'proj2', {rules: {includeSeries: ['vue']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = results.map(r => ({ + path: r.path, + fileName: r.path.split(/[/\\]/).pop() + })) + + expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule1.mdc')).toBe(true) // proj1 should have rule1 + expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule2.mdc')).toBe(false) + + expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule2.mdc')).toBe(true) // proj2 should have rule2 + expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule1.mdc')).toBe(false) + }) + + it('should return empty when include matches nothing', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const ruleFiles = results.filter(r => r.path.includes('rule-')) + + expect(ruleFiles).toHaveLength(0) + }) + }) + + describe('registerProjectOutputDirs', () => { + it('should register rules dir when project rules exist (directory registration is pre-filter)', async () => { + const rules = [ // The actual filtering happens in registerProjectOutputFiles and writeProjectOutputs // Note: registerProjectOutputDirs registers directories if any project rules exist + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputDirs(ctx) + const rulesDirs = results.filter(r => r.path.includes('rules')) + + expect(rulesDirs.length).toBeGreaterThan(0) // Directory is registered because rules exist (even if filtered out later) + }) + + it('should register rules dir when rules match filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputDirs(ctx) + const rulesDirs = results.filter(r => r.path.includes('rules')) + + expect(rulesDirs.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.test.ts b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.test.ts new file mode 100644 index 00000000..e0ea8445 --- /dev/null +++ b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.test.ts @@ -0,0 +1,833 @@ +import type { + FastCommandPrompt, + GlobalMemoryPrompt, + OutputPluginContext, + OutputWriteContext, + RelativePath, + RulePrompt +} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import {createLogger, FilePathKind, PromptKind} from '@truenine/plugin-shared' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {CursorOutputPlugin} from './CursorOutputPlugin' + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { + return { + pathKind: FilePathKind.Relative, + path: pathStr, + basePath, + getDirectoryName: () => pathStr, + getAbsolutePath: () => path.join(basePath, pathStr) + } +} + +function createMockGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt { + return { + type: PromptKind.GlobalMemory, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', basePath), + markdownContents: [] + } as GlobalMemoryPrompt +} + +function createMockFastCommandPrompt( + commandName: string, + series?: string, + basePath = '' +): FastCommandPrompt { + const content = 'Run something' + return { + type: PromptKind.FastCommand, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', basePath), + markdownContents: [], + yamlFrontMatter: {description: 'Fast command'}, + ...series != null && {series}, + commandName + } as FastCommandPrompt +} + +function createMockSkillPrompt( + name: string, + content = '# Skill', + basePath = '', + options?: {mcpConfig?: unknown} +) { + return { + yamlFrontMatter: {name, description: 'A skill'}, + dir: createMockRelativePath(name, basePath), + content, + length: content.length, + type: PromptKind.Skill, + filePathKind: FilePathKind.Relative, + markdownContents: [], + ...options + } +} + +class TestableCursorOutputPlugin extends CursorOutputPlugin { + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } + + public buildRuleMdcContentForTest(rule: RulePrompt): string { + return this.buildRuleMdcContent(rule) + } +} + +function createMockRulePrompt( + options: {series: string, ruleName: string, globs: readonly string[], content?: string} +): RulePrompt { + const content = options.content ?? '# Rule body' + return { + type: PromptKind.Rule, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', ''), + markdownContents: [], + yamlFrontMatter: {description: 'ignored', globs: options.globs}, + series: options.series, + ruleName: options.ruleName, + globs: options.globs, + scope: 'global' + } as RulePrompt +} + +describe('cursor output plugin', () => { + let tempDir: string, plugin: TestableCursorOutputPlugin + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-mcp-test-')) + plugin = new TestableCursorOutputPlugin() + plugin.setMockHomeDir(tempDir) + }) + + afterEach(() => { + if (tempDir != null && fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch { // ignore cleanup errors + } + } + }) + + describe('constructor', () => { + it('should have correct plugin name', () => expect(plugin.name).toBe('CursorOutputPlugin')) + + it('should depend on AgentsOutputPlugin', () => expect(plugin.dependsOn).toContain('AgentsOutputPlugin')) + }) + + describe('buildRuleMdcContent (Cursor rules front matter)', () => { + it('should output only alwaysApply and globs in front matter', () => { + const rule = createMockRulePrompt({ + series: 'cursor', + ruleName: 'ts', + globs: ['**/*.ts'], + content: '# TypeScript rule' + }) + const raw = plugin.buildRuleMdcContentForTest(rule) + const lines = raw.split('\n') + const start = lines.indexOf('---') + const end = lines.indexOf('---', start + 1) + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + const fmLines = lines.slice(start + 1, end).filter(l => l.trim().length > 0) + const keys = fmLines.map(l => l.split(':')[0]!.trim()).sort() + expect(keys).toEqual(['alwaysApply', 'globs']) + }) + + it('should set alwaysApply to false', () => { + const rule = createMockRulePrompt({ + series: 'cursor', + ruleName: 'ts', + globs: ['**/*.ts'], + content: '# Body' + }) + const raw = plugin.buildRuleMdcContentForTest(rule) + const lines = raw.split('\n') + const fmLine = lines.find(l => l.trimStart().startsWith('alwaysApply:')) + expect(fmLine).toBeDefined() + expect(fmLine).toBe('alwaysApply: false') + }) + + it('should output globs as comma-separated string, not YAML array', () => { + const rule = createMockRulePrompt({ + series: 'cursor', + ruleName: 'ts', + globs: ['**/*.ts', '**/*.tsx'], + content: '# Body' + }) + const raw = plugin.buildRuleMdcContentForTest(rule) + const lines = raw.split('\n') + const globsLine = lines.find(l => l.trimStart().startsWith('globs:')) + expect(globsLine).toBeDefined() + expect(globsLine).toBe('globs: **/*.ts, **/*.tsx') + }) + + it('should output single glob as string without trailing comma', () => { + const rule = createMockRulePrompt({ + series: 'cursor', + ruleName: 'ts', + globs: ['**/*.ts'], + content: '# Body' + }) + const raw = plugin.buildRuleMdcContentForTest(rule) + const lines = raw.split('\n') + const globsLine = lines.find(l => l.trimStart().startsWith('globs:')) + expect(globsLine).toBeDefined() + expect(globsLine).toBe('globs: **/*.ts') + }) + + it('should output empty string for empty globs', () => { + const rule = createMockRulePrompt({ + series: 'cursor', + ruleName: 'empty', + globs: [], + content: '# Body' + }) + const raw = plugin.buildRuleMdcContentForTest(rule) + const parsed = parseMarkdown(raw) + const fm = parsed.yamlFrontMatter as Record + expect(fm.globs).toBe('') + }) + + it('should not contain YAML array syntax for globs in raw output', () => { + const rule = createMockRulePrompt({ + series: 'cursor', + ruleName: 'multi', + globs: ['src/**', 'lib/**'], + content: '# Body' + }) + const raw = plugin.buildRuleMdcContentForTest(rule) + expect(raw).not.toMatch(/\n\s*-\s+/) + expect(raw).not.toContain(' - ') + }) + + it('should preserve rule body after front matter', () => { + const body = '# My Rule\n\nOnly for **/*.kt.' + const rule = createMockRulePrompt({ + series: 'cursor', + ruleName: 'kt', + globs: ['**/*.kt'], + content: body + }) + const raw = plugin.buildRuleMdcContentForTest(rule) + const parsed = parseMarkdown(raw) + expect(parsed.contentWithoutFrontMatter.trim()).toBe(body) + }) + + it('should not wrap glob patterns with double quotes in front matter', () => { + const rule = createMockRulePrompt({ + series: 'cursor', + ruleName: 'sql', + globs: ['**/*.sql'], + content: '# SQL rule' + }) + const raw = plugin.buildRuleMdcContentForTest(rule) + const lines = raw.split('\n') + const globsLine = lines.find(l => l.trimStart().startsWith('globs:')) + expect(globsLine).toBeDefined() + expect(globsLine).toBe('globs: **/*.sql') + }) + }) + + describe('registerGlobalOutputFiles', () => { + it('should register mcp.json and skill files when any skill has mcpConfig', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + { + ...createMockSkillPrompt('skill-a', '# Skill', tempDir), + mcpConfig: { + mcpServers: {foo: {command: 'npx', args: ['-y', 'mcp-foo']}} + } + } + ] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results.some(r => r.path === 'mcp.json')).toBe(true) + expect(results.some(r => r.path === path.join('skills-cursor', 'skill-a', 'SKILL.md'))).toBe(true) + expect(results.some(r => r.path === path.join('skills-cursor', 'skill-a', 'mcp.json'))).toBe(true) + const mcpEntry = results.find(r => r.path === 'mcp.json') + expect(mcpEntry?.getAbsolutePath()).toBe(path.join(tempDir, '.cursor', 'mcp.json')) + }) + + it('should not register mcp.json when no skill has mcpConfig but register skill files', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [createMockSkillPrompt('skill-a', '# Skill', tempDir)] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results.some(r => r.path === 'mcp.json')).toBe(false) + expect(results.some(r => r.path === path.join('skills-cursor', 'skill-a', 'SKILL.md'))).toBe(true) + }) + + it('should not register mcp.json when skills is empty', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results).toHaveLength(0) + }) + + it('should register command files under commands/ when fastCommands exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [ + createMockFastCommandPrompt('compile', 'build', tempDir), + createMockFastCommandPrompt('test', void 0, tempDir) + ] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results.length).toBeGreaterThanOrEqual(2) + const paths = results.map(r => r.path) + expect(paths).toContain(path.join('commands', 'build-compile.md')) + expect(paths).toContain(path.join('commands', 'test.md')) + const compileEntry = results.find(r => r.path.includes('build-compile')) + expect(compileEntry?.getAbsolutePath()).toBe(path.join(tempDir, '.cursor', 'commands', 'build-compile.md')) + }) + + it('should register both mcp.json and command files when skills have mcpConfig and fastCommands exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + { + ...createMockSkillPrompt('skill-a', '# Skill', tempDir), + mcpConfig: { + mcpServers: {foo: {command: 'npx', args: ['-y', 'mcp-foo']}}, + rawContent: '{}' + } + } + ], + fastCommands: [createMockFastCommandPrompt('lint', void 0, tempDir)] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results.some(r => r.path === 'mcp.json')).toBe(true) + expect(results.some(r => r.path === path.join('commands', 'lint.md'))).toBe(true) + }) + + it('should not register preserved skill files (create-rule, create-skill, etc.)', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + createMockSkillPrompt('create-rule', '# Skill', tempDir), + createMockSkillPrompt('my-custom-skill', '# Skill', tempDir) + ] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results.some(r => r.path.includes('create-rule'))).toBe(false) + expect(results.some(r => r.path === path.join('skills-cursor', 'my-custom-skill', 'SKILL.md'))).toBe(true) + }) + }) + + describe('registerGlobalOutputDirs', () => { + it('should return empty when no fastCommands and no skills', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + expect(results).toHaveLength(0) + }) + + it('should register commands dir when fastCommands exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [createMockFastCommandPrompt('compile', void 0, tempDir)] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + expect(results).toHaveLength(1) + expect(results[0].path).toBe('commands') + expect(results[0].getAbsolutePath()).toBe(path.join(tempDir, '.cursor', 'commands')) + }) + + it('should register skills-cursor/ when skills exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + createMockSkillPrompt('custom-skill', '# Skill', tempDir) + ] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + const skillDirs = results.filter(r => r.path.startsWith('skills-cursor')) + expect(skillDirs).toHaveLength(1) + expect(skillDirs[0].path).toBe(path.join('skills-cursor', 'custom-skill')) + expect(skillDirs[0].getAbsolutePath()).toBe(path.join(tempDir, '.cursor', 'skills-cursor', 'custom-skill')) + }) + + it('should not register preserved skill dirs (create-rule, create-skill, etc.)', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + createMockSkillPrompt('create-rule', '# Skill', tempDir), + createMockSkillPrompt('custom-skill', '# Skill', tempDir) + ] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + const skillDirs = results.filter(r => r.path.startsWith('skills-cursor')) + expect(skillDirs).toHaveLength(1) + expect(skillDirs[0].path).toBe(path.join('skills-cursor', 'custom-skill')) + expect(results.some(r => r.path.includes('create-rule'))).toBe(false) + }) + }) + + describe('canWrite', () => { + it('should return true when skills exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [{yamlFrontMatter: {name: 's'}, dir: createMockRelativePath('s', tempDir)}] + } + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + expect(result).toBe(true) + }) + + it('should return false when no skills and no fastCommands', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [] + } + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + expect(result).toBe(false) + }) + + it('should return true when only fastCommands exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [createMockFastCommandPrompt('lint', void 0, tempDir)] + } + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + expect(result).toBe(true) + }) + }) + + describe('writeGlobalOutputs', () => { + it('should write merged mcp.json with stdio server from skills', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + { + ...createMockSkillPrompt('skill-a', '# Skill', tempDir), + mcpConfig: { + mcpServers: { + myServer: {command: 'npx', args: ['-y', 'mcp-server'], env: {API_KEY: 'secret'}} + }, + rawContent: '{"mcpServers":{"myServer":{"command":"npx","args":["-y","mcp-server"],"env":{"API_KEY":"secret"}}}}' + } + } + ] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeGlobalOutputs(ctx) + expect(results.files.length).toBeGreaterThanOrEqual(2) + expect(results.files.some(f => f.path.path === 'mcp.json')).toBe(true) + expect(results.files.every(f => f.success)).toBe(true) + + const mcpPath = path.join(tempDir, '.cursor', 'mcp.json') + expect(fs.existsSync(mcpPath)).toBe(true) + const content = JSON.parse(fs.readFileSync(mcpPath, 'utf8')) as Record + expect(content.mcpServers).toBeDefined() + const servers = content.mcpServers as Record + expect(servers.myServer).toEqual({ + command: 'npx', + args: ['-y', 'mcp-server'], + env: {API_KEY: 'secret'} + }) + }) + + it('should merge with existing mcp.json and preserve user entries', async () => { + const cursorDir = path.join(tempDir, '.cursor') + fs.mkdirSync(cursorDir, {recursive: true}) + const mcpPath = path.join(cursorDir, 'mcp.json') + const existing = { + mcpServers: { + userServer: {command: 'python', args: ['server.py']}, + fromSkill: {url: 'https://old.example.com/mcp'} + } + } + fs.writeFileSync(mcpPath, JSON.stringify(existing, null, 2)) + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + { + ...createMockSkillPrompt('skill-a', '# Skill', tempDir), + mcpConfig: { + mcpServers: { + fromSkill: {command: 'npx', args: ['-y', 'new-skill-mcp']} + }, + rawContent: '{}' + } + } + ] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + await plugin.writeGlobalOutputs(ctx) + + const content = JSON.parse(fs.readFileSync(mcpPath, 'utf8')) as Record + const servers = content.mcpServers as Record + expect(servers.userServer).toEqual({command: 'python', args: ['server.py']}) + expect(servers.fromSkill).toEqual({command: 'npx', args: ['-y', 'new-skill-mcp']}) + }) + + it('should transform remote server url or serverUrl to url', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + { + ...createMockSkillPrompt('skill-remote', '# Skill', tempDir), + mcpConfig: { + mcpServers: { + remote: {serverUrl: 'https://api.example.com/mcp', headers: {Authorization: 'Bearer x'}} + }, + rawContent: '{}' + } + } + ] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + await plugin.writeGlobalOutputs(ctx) + + const mcpPath = path.join(tempDir, '.cursor', 'mcp.json') + const content = JSON.parse(fs.readFileSync(mcpPath, 'utf8')) as Record + const servers = content.mcpServers as Record + expect(servers.remote).toEqual({ + url: 'https://api.example.com/mcp', + headers: {Authorization: 'Bearer x'} + }) + }) + + it('should write fast command files to ~/.cursor/commands/', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [ + createMockFastCommandPrompt('compile', 'build', tempDir), + createMockFastCommandPrompt('test', void 0, tempDir) + ] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeGlobalOutputs(ctx) + expect(results.files).toHaveLength(2) + + const commandsDir = path.join(tempDir, '.cursor', 'commands') + expect(fs.existsSync(commandsDir)).toBe(true) + + const buildCompilePath = path.join(commandsDir, 'build-compile.md') + const testPath = path.join(commandsDir, 'test.md') + expect(fs.existsSync(buildCompilePath)).toBe(true) + expect(fs.existsSync(testPath)).toBe(true) + + const buildCompileContent = fs.readFileSync(buildCompilePath, 'utf8') + expect(buildCompileContent).toContain('description: Fast command') + expect(buildCompileContent).toContain('Run something') + + const testContent = fs.readFileSync(testPath, 'utf8') + expect(testContent).toContain('Run something') + }) + + it('should write skill to ~/.cursor/skills-cursor//SKILL.md', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [createMockSkillPrompt('my-skill', '# My Skill Content', tempDir)] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + await plugin.writeGlobalOutputs(ctx) + + const skillPath = path.join(tempDir, '.cursor', 'skills-cursor', 'my-skill', 'SKILL.md') + expect(fs.existsSync(skillPath)).toBe(true) + const content = fs.readFileSync(skillPath, 'utf8') + expect(content).toContain('name: my-skill') + expect(content).toContain('# My Skill Content') + }) + + it('should not overwrite preserved skill (create-rule)', async () => { + const preservedSkillDir = path.join(tempDir, '.cursor', 'skills-cursor', 'create-rule') + fs.mkdirSync(preservedSkillDir, {recursive: true}) + const originalContent = '# Original Cursor built-in skill' + fs.writeFileSync(path.join(preservedSkillDir, 'SKILL.md'), originalContent) + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + createMockSkillPrompt('create-rule', '# Would overwrite', tempDir), + createMockSkillPrompt('custom-skill', '# Custom', tempDir) + ] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + await plugin.writeGlobalOutputs(ctx) + + const preservedPath = path.join(preservedSkillDir, 'SKILL.md') + expect(fs.readFileSync(preservedPath, 'utf8')).toBe(originalContent) + const customPath = path.join(tempDir, '.cursor', 'skills-cursor', 'custom-skill', 'SKILL.md') + expect(fs.existsSync(customPath)).toBe(true) + expect(fs.readFileSync(customPath, 'utf8')).toContain('# Custom') + }) + }) + + describe('clean effect', () => { + it('should reset mcp.json to empty mcpServers shell on clean', async () => { + const cursorDir = path.join(tempDir, '.cursor') + fs.mkdirSync(cursorDir, {recursive: true}) + const mcpPath = path.join(cursorDir, 'mcp.json') + fs.writeFileSync(mcpPath, JSON.stringify({mcpServers: {some: {command: 'npx'}}}, null, 2)) + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)} + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as any + + await plugin.onCleanComplete(ctx) + + expect(fs.existsSync(mcpPath)).toBe(true) + const content = JSON.parse(fs.readFileSync(mcpPath, 'utf8')) as Record + expect(content).toEqual({mcpServers: {}}) + }) + + it('should not write on clean when dryRun is true', async () => { + const cursorDir = path.join(tempDir, '.cursor') + fs.mkdirSync(cursorDir, {recursive: true}) + const mcpPath = path.join(cursorDir, 'mcp.json') + const original = {mcpServers: {keep: {command: 'npx'}}} + fs.writeFileSync(mcpPath, JSON.stringify(original, null, 2)) + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)} + }, + logger: createLogger('test', 'debug'), + dryRun: true + } as any + + await plugin.onCleanComplete(ctx) + + const content = JSON.parse(fs.readFileSync(mcpPath, 'utf8')) as Record + expect(content).toEqual(original) + }) + }) + + describe('project outputs', () => { + it('should implement writeProjectOutputs', () => expect(plugin.writeProjectOutputs).toBeDefined()) + + it('should implement registerProjectOutputFiles and registerProjectOutputDirs', () => { + expect(plugin.registerProjectOutputFiles).toBeDefined() + expect(plugin.registerProjectOutputDirs).toBeDefined() + }) + + it('should implement registerGlobalOutputDirs for commands dir', () => expect(plugin.registerGlobalOutputDirs).toBeDefined()) + + it('should register .cursor/rules dir for each project when globalMemory exists', async () => { + const projectDir = createMockRelativePath('project-a', tempDir) + const ctx = { + collectedInputContext: { + workspace: { + projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], + directory: createMockRelativePath('.', tempDir) + }, + globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) + } + } as unknown as OutputPluginContext + + const dirs = await plugin.registerProjectOutputDirs(ctx) + expect(dirs.length).toBe(1) + expect(dirs[0].path).toBe(path.join('project-a', '.cursor', 'rules')) + expect(dirs[0].getAbsolutePath()).toBe(path.join(tempDir, 'project-a', '.cursor', 'rules')) + }) + + it('should register .cursor/rules/global.mdc for each project when globalMemory exists', async () => { + const projectDir = createMockRelativePath('project-a', tempDir) + const ctx = { + collectedInputContext: { + workspace: { + projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], + directory: createMockRelativePath('.', tempDir) + }, + globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) + } + } as unknown as OutputPluginContext + + const files = await plugin.registerProjectOutputFiles(ctx) + 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 () => { + const projectDir = createMockRelativePath('project-a', tempDir) + const ctx = { + collectedInputContext: { + workspace: { + projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], + directory: createMockRelativePath('.', tempDir) + }, + globalMemory: void 0 + } + } as unknown as OutputPluginContext + + const dirs = await plugin.registerProjectOutputDirs(ctx) + const files = await plugin.registerProjectOutputFiles(ctx) + expect(dirs.length).toBe(0) + expect(files.length).toBe(0) + }) + + it('should return true from canWrite when only globalMemory and projects exist', async () => { + const projectDir = createMockRelativePath('project-a', tempDir) + const ctx = { + collectedInputContext: { + workspace: { + projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], + directory: createMockRelativePath('.', tempDir) + }, + globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir), + skills: [], + fastCommands: [] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + expect(result).toBe(true) + }) + + it('should write global.mdc with alwaysApply true and global content', async () => { + const projectDir = createMockRelativePath('project-a', tempDir) + const globalContent = '# Global prompt\n\nAlways apply this.' + const ctx = { + collectedInputContext: { + workspace: { + projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], + directory: createMockRelativePath('.', tempDir) + }, + globalMemory: createMockGlobalMemoryPrompt(globalContent, tempDir) + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeProjectOutputs(ctx) + expect(results.files.length).toBe(1) + expect(results.files[0].success).toBe(true) + + const fullPath = path.join(tempDir, 'project-a', '.cursor', 'rules', 'global.mdc') + expect(fs.existsSync(fullPath)).toBe(true) + const content = fs.readFileSync(fullPath, 'utf8') + expect(content).toContain('alwaysApply: true') + expect(content).toContain('Global prompt (synced)') + expect(content).toContain(globalContent) + }) + + it('should not write files on dryRun', async () => { + const projectDir = createMockRelativePath('project-a', tempDir) + const ctx = { + collectedInputContext: { + workspace: { + projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], + directory: createMockRelativePath('.', tempDir) + }, + globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) + }, + logger: createLogger('test', 'debug'), + dryRun: true + } as unknown as OutputWriteContext + + const results = await plugin.writeProjectOutputs(ctx) + expect(results.files.length).toBe(1) + expect(results.files[0].success).toBe(true) + + const fullPath = path.join(tempDir, 'project-a', '.cursor', 'rules', 'global.mdc') + expect(fs.existsSync(fullPath)).toBe(false) + }) + }) +}) diff --git a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts new file mode 100644 index 00000000..e9f5805e --- /dev/null +++ b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts @@ -0,0 +1,534 @@ +import type { + FastCommandPrompt, + OutputPluginContext, + OutputWriteContext, + Project, + RulePrompt, + SkillPrompt, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import {Buffer} from 'node:buffer' +import * as fs from 'node:fs' +import * as path from 'node:path' +import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' + +const GLOBAL_CONFIG_DIR = '.cursor' +const MCP_CONFIG_FILE = 'mcp.json' +const COMMANDS_SUBDIR = 'commands' +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', + 'create-skill', + 'create-subagent', + 'migrate-to-skills', + 'update-cursor-settings' +]) + +export class CursorOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('CursorOutputPlugin', { + globalConfigDir: GLOBAL_CONFIG_DIR, + outputFileName: '', + dependsOn: [PLUGIN_NAMES.AgentsOutput], + indexignore: '.cursorignore' + }) + + this.registerCleanEffect('mcp-config-cleanup', async ctx => { + const globalDir = this.getGlobalConfigDir() + const mcpConfigPath = path.join(globalDir, MCP_CONFIG_FILE) + const emptyMcpConfig = {mcpServers: {}} + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'mcpConfigCleanup', path: mcpConfigPath}) + return {success: true, description: 'Would reset mcp.json to empty shell'} + } + + try { + this.ensureDirectory(globalDir) + fs.writeFileSync(mcpConfigPath, JSON.stringify(emptyMcpConfig, null, 2)) + this.log.trace({action: 'clean', type: 'mcpConfigCleanup', path: mcpConfigPath}) + return {success: true, description: 'Reset mcp.json to empty shell'} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'clean', type: 'mcpConfigCleanup', path: mcpConfigPath, error: errMsg}) + return {success: false, error: error as Error, description: 'Failed to reset mcp.json'} + } + }) + } + + async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const globalDir = this.getGlobalConfigDir() + const {fastCommands, skills, rules} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + + if (fastCommands != null && fastCommands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + if (filteredCommands.length > 0) { + const commandsDir = this.getGlobalCommandsDir() + results.push({pathKind: FilePathKind.Relative, path: COMMANDS_SUBDIR, basePath: globalDir, getDirectoryName: () => COMMANDS_SUBDIR, getAbsolutePath: () => commandsDir}) + } + } + + if (skills != null && skills.length > 0) { + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter.name + if (this.isPreservedSkill(skillName)) continue + const skillPath = path.join(globalDir, SKILLS_CURSOR_SUBDIR, skillName) + results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => skillPath}) + } + } + + const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === '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 + } + + async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const globalDir = this.getGlobalConfigDir() + const {skills, fastCommands} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] + const hasAnyMcpConfig = filteredSkills.some(s => s.mcpConfig != null) + + if (hasAnyMcpConfig) { + const mcpConfigPath = path.join(globalDir, MCP_CONFIG_FILE) + results.push({pathKind: FilePathKind.Relative, path: MCP_CONFIG_FILE, basePath: globalDir, getDirectoryName: () => GLOBAL_CONFIG_DIR, getAbsolutePath: () => mcpConfigPath}) + } + + if (fastCommands != null && fastCommands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + const commandsDir = this.getGlobalCommandsDir() + const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + for (const cmd of filteredCommands) { + const fileName = this.transformFastCommandName(cmd, transformOptions) + const fullPath = path.join(commandsDir, fileName) + results.push({pathKind: FilePathKind.Relative, path: path.join(COMMANDS_SUBDIR, fileName), basePath: globalDir, getDirectoryName: () => COMMANDS_SUBDIR, getAbsolutePath: () => fullPath}) + } + } + + const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === '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 (filteredSkills.length === 0) return results + + const skillsCursorDir = this.getSkillsCursorDir() + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter.name + if (this.isPreservedSkill(skillName)) continue + const skillDir = path.join(skillsCursorDir, skillName) + results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, SKILL_FILE_NAME), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, SKILL_FILE_NAME)}) + + if (skill.mcpConfig != null) results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, MCP_CONFIG_FILE), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, MCP_CONFIG_FILE)}) + + if (skill.childDocs != null) { + for (const childDoc of skill.childDocs) { + const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') + results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, outputRelativePath), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, outputRelativePath)}) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, resource.relativePath), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, resource.relativePath)}) + } + } + return results + } + + async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {workspace, globalMemory, rules} = ctx.collectedInputContext + const hasProjectRules = rules?.some(r => this.normalizeRuleScope(r) === 'project') ?? false + if (globalMemory == null && !hasProjectRules) return results + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + results.push(this.createProjectRulesDirRelativePath(projectDir)) + } + return results + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {workspace, globalMemory, rules} = ctx.collectedInputContext + if (globalMemory == null && rules == null) 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 (rules != null && rules.length > 0) { + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + const projectRules = applySubSeriesGlobPrefix(filterRulesByProjectConfig(rules.filter(r => this.normalizeRuleScope(r) === 'project'), project.projectConfig), project.projectConfig) + for (const rule of projectRules) results.push(this.createProjectRuleFileRelativePath(projectDir, this.buildRuleFileName(rule))) + } + } + + results.push(...this.registerProjectIgnoreOutputFiles(workspace.projects)) + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + 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 || hasRules || hasCursorIgnore) return true + this.log.trace({action: 'skip', reason: 'noOutputs'}) + return false + } + + async writeGlobalOutputs(ctx: OutputWriteContext): Promise { + const {skills, fastCommands, rules} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + if (skills != null && skills.length > 0) { + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + const mcpResult = await this.writeGlobalMcpConfig(ctx, filteredSkills) + if (mcpResult != null) fileResults.push(mcpResult) + const skillsCursorDir = this.getSkillsCursorDir() + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter.name + if (this.isPreservedSkill(skillName)) continue + fileResults.push(...await this.writeGlobalSkill(ctx, skillsCursorDir, skill)) + } + } + + if (fastCommands != null && fastCommands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + const commandsDir = this.getGlobalCommandsDir() + for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalFastCommand(ctx, commandsDir, cmd)) + } + + const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global') + if (globalRules == null || globalRules.length === 0) return {files: fileResults, dirs: dirResults} + + const globalRulesDir = path.join(this.getGlobalConfigDir(), RULES_SUBDIR) + for (const rule of globalRules) fileResults.push(await this.writeRuleMdcFile(ctx, globalRulesDir, rule, this.getGlobalConfigDir())) + return {files: fileResults, dirs: dirResults} + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + const {workspace, globalMemory, rules} = ctx.collectedInputContext + if (globalMemory != null) { + const content = this.buildGlobalRuleContent(globalMemory.content as string) + for (const project of workspace.projects) { + if (project.dirFromWorkspacePath == null) continue + fileResults.push(await this.writeProjectGlobalRule(ctx, project, content)) + } + } + + if (rules != null && rules.length > 0) { + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + const projectRules = applySubSeriesGlobPrefix(filterRulesByProjectConfig(rules.filter(r => this.normalizeRuleScope(r) === 'project'), project.projectConfig), project.projectConfig) + if (projectRules.length === 0) continue + const rulesDir = path.join(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR) + for (const rule of projectRules) fileResults.push(await this.writeRuleMdcFile(ctx, rulesDir, rule, projectDir.basePath)) + } + } + + fileResults.push(...await this.writeProjectIgnoreFiles(ctx)) + return {files: fileResults, dirs: dirResults} + } + + private createProjectRulesDirRelativePath(projectDir: RelativePath): RelativePath { + const rulesDirPath = path.join(projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR) + return {pathKind: FilePathKind.Relative, path: rulesDirPath, basePath: projectDir.basePath, getDirectoryName: () => RULES_SUBDIR, getAbsolutePath: () => path.join(projectDir.basePath, rulesDirPath)} + } + + private createProjectRuleFileRelativePath(projectDir: RelativePath, fileName: string): RelativePath { + const filePath = path.join(projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR, fileName) + return {pathKind: FilePathKind.Relative, path: filePath, basePath: projectDir.basePath, getDirectoryName: () => RULES_SUBDIR, getAbsolutePath: () => path.join(projectDir.basePath, filePath)} + } + + private buildGlobalRuleContent(content: string): string { + return buildMarkdownWithFrontMatter({description: 'Global prompt (synced)', alwaysApply: true}, content) + } + + private async writeProjectGlobalRule(ctx: OutputWriteContext, project: Project, content: string): Promise { + const projectDir = project.dirFromWorkspacePath! + const rulesDir = path.join(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR) + const fullPath = path.join(rulesDir, GLOBAL_RULE_FILE) + const relativePath = this.createProjectRuleFileRelativePath(projectDir, GLOBAL_RULE_FILE) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'globalRule', path: fullPath}) + return {path: relativePath, success: true, skipped: false} + } + + try { + this.ensureDirectory(rulesDir) + this.writeFileSync(fullPath, content) + this.log.trace({action: 'write', type: 'globalRule', path: fullPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'globalRule', path: fullPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private isPreservedSkill(name: string): boolean { return PRESERVED_SKILLS.has(name) } + private getSkillsCursorDir(): string { return path.join(this.getGlobalConfigDir(), SKILLS_CURSOR_SUBDIR) } + private getGlobalCommandsDir(): string { return path.join(this.getGlobalConfigDir(), COMMANDS_SUBDIR) } + + private async writeGlobalFastCommand(ctx: OutputWriteContext, commandsDir: string, cmd: FastCommandPrompt): Promise { + const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + const fileName = this.transformFastCommandName(cmd, transformOptions) + const fullPath = path.join(commandsDir, fileName) + const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(COMMANDS_SUBDIR, fileName), basePath: this.getGlobalConfigDir(), getDirectoryName: () => COMMANDS_SUBDIR, getAbsolutePath: () => fullPath} + const content = this.buildMarkdownContentWithRaw(cmd.content, cmd.yamlFrontMatter, cmd.rawFrontMatter) + + if (ctx.dryRun === true) { this.log.trace({action: 'dryRun', type: 'globalFastCommand', path: fullPath}); return {path: relativePath, success: true, skipped: false} } + + try { + this.ensureDirectory(commandsDir) + fs.writeFileSync(fullPath, content) + this.log.trace({action: 'write', type: 'globalFastCommand', path: fullPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'globalFastCommand', path: fullPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private async writeGlobalMcpConfig(ctx: OutputWriteContext, skills: readonly SkillPrompt[]): Promise { + const mergedMcpServers: Record = {} + for (const skill of skills) { + if (skill.mcpConfig == null) continue + for (const [mcpName, mcpConfig] of Object.entries(skill.mcpConfig.mcpServers)) mergedMcpServers[mcpName] = this.transformMcpConfigForCursor({...(mcpConfig as unknown as Record)}) + } + if (Object.keys(mergedMcpServers).length === 0) return null + + const globalDir = this.getGlobalConfigDir() + const mcpConfigPath = path.join(globalDir, MCP_CONFIG_FILE) + const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: MCP_CONFIG_FILE, basePath: globalDir, getDirectoryName: () => GLOBAL_CONFIG_DIR, getAbsolutePath: () => mcpConfigPath} + + let existingConfig: Record = {} + try { if (this.existsSync(mcpConfigPath)) existingConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')) as Record } + catch { existingConfig = {} } + + const existingMcpServers = (existingConfig['mcpServers'] as Record) ?? {} + existingConfig['mcpServers'] = {...existingMcpServers, ...mergedMcpServers} + const content = JSON.stringify(existingConfig, null, 2) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'globalMcpConfig', path: mcpConfigPath, serverCount: Object.keys(mergedMcpServers).length}) + return {path: relativePath, success: true, skipped: false} + } + + try { + this.ensureDirectory(globalDir) + fs.writeFileSync(mcpConfigPath, content) + this.log.trace({action: 'write', type: 'globalMcpConfig', path: mcpConfigPath, serverCount: Object.keys(mergedMcpServers).length}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'globalMcpConfig', path: mcpConfigPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private transformMcpConfigForCursor(config: Record): Record { + const result: Record = {} + if (config['command'] != null) { + result['command'] = config['command'] + if (config['args'] != null) result['args'] = config['args'] + if (config['env'] != null) result['env'] = config['env'] + return result + } + const url = config['url'] ?? config['serverUrl'] + if (url == null) return result + result['url'] = url + if (config['headers'] != null) result['headers'] = config['headers'] + return result + } + + private async writeGlobalSkill(ctx: OutputWriteContext, skillsDir: string, skill: SkillPrompt): Promise { + const results: WriteResult[] = [] + const skillName = skill.yamlFrontMatter.name + const skillDir = path.join(skillsDir, skillName) + const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) + const globalDir = this.getGlobalConfigDir() + const skillRelativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, SKILL_FILE_NAME), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => skillFilePath} + + const frontMatterData = this.buildSkillFrontMatter(skill) + const skillContent = buildMarkdownWithFrontMatter(frontMatterData, skill.content as string) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'skill', path: skillFilePath}) + results.push({path: skillRelativePath, success: true, skipped: false}) + } else { + try { + this.ensureDirectory(skillDir) + this.writeFileSync(skillFilePath, skillContent) + this.log.trace({action: 'write', type: 'skill', path: skillFilePath}) + results.push({path: skillRelativePath, success: true}) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'skill', path: skillFilePath, error: errMsg}) + results.push({path: skillRelativePath, success: false, error: error as Error}) + } + } + + if (skill.mcpConfig != null) results.push(await this.writeSkillMcpConfig(ctx, skill, skillDir, globalDir)) + if (skill.childDocs != null) { for (const childDoc of skill.childDocs) results.push(await this.writeSkillChildDoc(ctx, childDoc, skillDir, skillName, globalDir)) } + if (skill.resources != null) { for (const resource of skill.resources) results.push(await this.writeSkillResource(ctx, resource, skillDir, skillName, globalDir)) } + return results + } + + private buildSkillFrontMatter(skill: SkillPrompt): Record { + const fm = skill.yamlFrontMatter + return {name: fm.name, description: fm.description, ...fm.displayName != null && {displayName: fm.displayName}, ...fm.keywords != null && fm.keywords.length > 0 && {keywords: fm.keywords}, ...fm.author != null && {author: fm.author}, ...fm.version != null && {version: fm.version}, ...fm.allowTools != null && fm.allowTools.length > 0 && {allowTools: fm.allowTools}} + } + + private async writeSkillMcpConfig(ctx: OutputWriteContext, skill: SkillPrompt, skillDir: string, globalDir: string): Promise { + const skillName = skill.yamlFrontMatter.name + const mcpConfigPath = path.join(skillDir, MCP_CONFIG_FILE) + const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, MCP_CONFIG_FILE), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => mcpConfigPath} + const mcpConfigContent = skill.mcpConfig!.rawContent + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'mcpConfig', path: mcpConfigPath}) + return {path: relativePath, success: true, skipped: false} + } + try { + this.ensureDirectory(skillDir) + this.writeFileSync(mcpConfigPath, mcpConfigContent) + this.log.trace({action: 'write', type: 'mcpConfig', path: mcpConfigPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'mcpConfig', path: mcpConfigPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private async writeSkillChildDoc(ctx: OutputWriteContext, childDoc: {relativePath: string, content: unknown}, skillDir: string, skillName: string, globalDir: string): Promise { + const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') + const childDocPath = path.join(skillDir, outputRelativePath) + const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, outputRelativePath), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => childDocPath} + const content = childDoc.content as string + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'childDoc', path: childDocPath}) + return {path: relativePath, success: true, skipped: false} + } + try { + const parentDir = path.dirname(childDocPath) + this.ensureDirectory(parentDir) + this.writeFileSync(childDocPath, content) + this.log.trace({action: 'write', type: 'childDoc', path: childDocPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'childDoc', path: childDocPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private async writeSkillResource(ctx: OutputWriteContext, resource: {relativePath: string, content: string, encoding: 'text' | 'base64'}, skillDir: string, skillName: string, globalDir: string): Promise { + const resourcePath = path.join(skillDir, resource.relativePath) + const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, resource.relativePath), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => resourcePath} + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'resource', path: resourcePath}) + return {path: relativePath, success: true, skipped: false} + } + try { + const parentDir = path.dirname(resourcePath) + this.ensureDirectory(parentDir) + if (resource.encoding === 'base64') { + const buffer = Buffer.from(resource.content, 'base64') + this.writeFileSyncBuffer(resourcePath, buffer) + } else this.writeFileSync(resourcePath, resource.content) + this.log.trace({action: 'write', type: 'resource', path: resourcePath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'resource', path: resourcePath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private buildRuleFileName(rule: RulePrompt): string { return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.mdc` } + + protected buildRuleMdcContent(rule: RulePrompt): string { + const fmData: Record = {alwaysApply: false, globs: rule.globs.length > 0 ? rule.globs.join(', ') : ''} + const raw = buildMarkdownWithFrontMatter(fmData, rule.content) + const lines = raw.split('\n') + const transformedLines = lines.map(line => { + const match = /^(\s*globs:\s*)(['"])(.*)\2\s*$/.exec(line) + if (match == null) return line + const prefix = match[1] ?? 'globs: ' + const value = match[3] ?? '' + if (value.trim().length === 0) return line + return `${prefix}${value}` + }) + return transformedLines.join('\n') + } + + 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/plugin-cursor/index.ts b/cli/src/plugins/plugin-cursor/index.ts new file mode 100644 index 00000000..4c94c1bb --- /dev/null +++ b/cli/src/plugins/plugin-cursor/index.ts @@ -0,0 +1,3 @@ +export { + CursorOutputPlugin +} from './CursorOutputPlugin' diff --git a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts new file mode 100644 index 00000000..0cad3c85 --- /dev/null +++ b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts @@ -0,0 +1,269 @@ +import type {CollectedInputContext, FastCommandPrompt, OutputPluginContext, Project, RelativePath, RootPath, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {DroidCLIOutputPlugin} from './DroidCLIOutputPlugin' + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { // Helper to create mock RelativePath + return { + pathKind: FilePathKind.Relative, + path: pathStr, + basePath, + getDirectoryName: () => pathStr, + getAbsolutePath: () => path.join(basePath, pathStr) + } +} + +class TestableDroidCLIOutputPlugin extends DroidCLIOutputPlugin { // Testable subclass to mock home dir + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } +} + +describe('droidCLIOutputPlugin', () => { + let tempDir: string, + plugin: TestableDroidCLIOutputPlugin, + mockContext: OutputPluginContext + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'droid-test-')) + plugin = new TestableDroidCLIOutputPlugin() + plugin.setMockHomeDir(tempDir) + + mockContext = { + collectedInputContext: { + workspace: { + projects: [], + directory: createMockRelativePath('.', tempDir) + }, + globalMemory: { + type: PromptKind.GlobalMemory, + content: 'Global Memory Content', + filePathKind: FilePathKind.Absolute, + dir: createMockRelativePath('.', tempDir), + markdownContents: [] + }, + fastCommands: [], + subAgents: [], + skills: [] + } as unknown as CollectedInputContext, + logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, + fs, + path, + glob: {} as any + } + }, 30000) + + afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch { + } // ignore cleanup errors + } + }) + + describe('registerGlobalOutputDirs', () => { + it('should register commands, agents, and skills subdirectories in .factory', async () => { + const dirs = await plugin.registerGlobalOutputDirs(mockContext) + + const dirPaths = dirs.map(d => d.path) + expect(dirPaths).toContain('commands') + expect(dirPaths).toContain('agents') + expect(dirPaths).toContain('skills') + + const expectedBasePath = path.join(tempDir, '.factory') + dirs.forEach(d => expect(d.basePath).toBe(expectedBasePath)) + }) + }) + + describe('registerProjectOutputDirs', () => { + it('should register project cleanup directories', async () => { + const mockProject: Project = { + name: 'test-project', + dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), + rootMemoryPrompt: { + type: PromptKind.ProjectRootMemory, + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', tempDir) as unknown as RootPath, + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} + }, + childMemoryPrompts: [] + } + + const ctxWithProject = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + workspace: { + ...mockContext.collectedInputContext.workspace, + projects: [mockProject] + } + } + } + + const dirs = await plugin.registerProjectOutputDirs(ctxWithProject) + const dirPaths = dirs.map(d => d.path) // Expect 3 dirs: .factory/commands, .factory/agents, .factory/skills + + expect(dirPaths.some(p => p.includes(path.join('.factory', 'commands')))).toBe(true) + expect(dirPaths.some(p => p.includes(path.join('.factory', 'agents')))).toBe(true) + expect(dirPaths.some(p => p.includes(path.join('.factory', 'skills')))).toBe(true) + }) + }) + + describe('registerGlobalOutputFiles', () => { + it('should register AGENTS.md in global config dir', async () => { + const files = await plugin.registerGlobalOutputFiles(mockContext) + const outputFile = files.find(f => f.path === 'AGENTS.md') + + expect(outputFile).toBeDefined() + expect(outputFile?.basePath).toBe(path.join(tempDir, '.factory')) + }) + + it('should register fast commands in commands subdirectory', async () => { + const mockCmd: FastCommandPrompt = { + type: PromptKind.FastCommand, + commandName: 'test-cmd', + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('test-cmd', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, description: 'desc'} + } + + const ctxWithCmd = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + fastCommands: [mockCmd] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithCmd) + const cmdFile = files.find(f => f.path.includes('test-cmd.md')) + + expect(cmdFile).toBeDefined() + expect(cmdFile?.path).toContain('commands') + expect(cmdFile?.basePath).toBe(path.join(tempDir, '.factory')) + }) + + it('should register sub agents in agents subdirectory', async () => { + const mockAgent: SubAgentPrompt = { + type: PromptKind.SubAgent, + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('test-agent.md', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'agent', description: 'desc'} + } + + const ctxWithAgent = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + subAgents: [mockAgent] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) + const agentFile = files.find(f => f.path.includes('test-agent.md')) + + expect(agentFile).toBeDefined() + expect(agentFile?.path).toContain('agents') + expect(agentFile?.basePath).toBe(path.join(tempDir, '.factory')) + }) + + it('should strip .mdx suffix from sub agent path and use .md', async () => { + const mockAgent: SubAgentPrompt = { + type: PromptKind.SubAgent, + content: 'agent content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('code-review.cn.mdx', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'desc'} + } + + const ctxWithAgent = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + subAgents: [mockAgent] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) + const agentFile = files.find(f => f.path.includes('agents')) + + expect(agentFile).toBeDefined() + expect(agentFile?.path).toContain('code-review.cn.md') + expect(agentFile?.path).not.toContain('.mdx') + }) + + it('should register skills in skills subdirectory', 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'} + } + + const ctxWithSkill = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + skills: [mockSkill] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) + const skillFile = files.find(f => f.path.includes('SKILL.md')) + + expect(skillFile).toBeDefined() + expect(skillFile?.path).toContain('skills') + expect(skillFile?.basePath).toBe(path.join(tempDir, '.factory')) + }) + }) + + describe('registerProjectOutputFiles', () => { + it('should return empty array', async () => { + const mockProject: Project = { + name: 'test-project', + dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), + childMemoryPrompts: [] + } + + const ctxWithProject = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + workspace: { + ...mockContext.collectedInputContext.workspace, + projects: [mockProject] + } + } + } + + const files = await plugin.registerProjectOutputFiles(ctxWithProject) + expect(files).toEqual([]) + }) + }) +}) diff --git a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts new file mode 100644 index 00000000..3d4333db --- /dev/null +++ b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts @@ -0,0 +1,58 @@ +import type { + OutputWriteContext, + SkillPrompt, + WriteResult +} from '@truenine/plugin-shared' +import * as path from 'node:path' +import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' + +const GLOBAL_MEMORY_FILE = 'AGENTS.md' +const GLOBAL_CONFIG_DIR = '.factory' + +export class DroidCLIOutputPlugin extends BaseCLIOutputPlugin { + constructor() { + super('DroidCLIOutputPlugin', { + globalConfigDir: GLOBAL_CONFIG_DIR, + outputFileName: GLOBAL_MEMORY_FILE, + supportsFastCommands: true, + supportsSubAgents: true, + supportsSkills: true + }) // Droid uses default subdir names + } + + protected override async writeSkill( // Override writeSkill to preserve simplified front matter logic + ctx: OutputWriteContext, + basePath: string, + skill: SkillPrompt + ): Promise { + const results: WriteResult[] = [] + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const targetDir = path.join(basePath, this.skillsSubDir, skillName) + const fullPath = path.join(targetDir, 'SKILL.md') + + const simplifiedFrontMatter = skill.yamlFrontMatter != null // Droid-specific: Simplify front matter + ? {name: skill.yamlFrontMatter.name, description: skill.yamlFrontMatter.description} + : void 0 + + const content = this.buildMarkdownContent(skill.content as string, simplifiedFrontMatter) + + const mainFileResult = await this.writeFile(ctx, fullPath, content, 'skill') + results.push(mainFileResult) + + if (skill.childDocs != null) { + for (const refDoc of skill.childDocs) { + const refResults = await this.writeSkillReferenceDocument(ctx, targetDir, skillName, refDoc, basePath) + results.push(...refResults) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + const refResults = await this.writeSkillResource(ctx, targetDir, skillName, resource, basePath) + results.push(...refResults) + } + } + + return results + } +} diff --git a/cli/src/plugins/plugin-droid-cli/index.ts b/cli/src/plugins/plugin-droid-cli/index.ts new file mode 100644 index 00000000..040d09e7 --- /dev/null +++ b/cli/src/plugins/plugin-droid-cli/index.ts @@ -0,0 +1,3 @@ +export { + DroidCLIOutputPlugin +} from './DroidCLIOutputPlugin' diff --git a/cli/src/plugins/plugin-editorconfig/EditorConfigOutputPlugin.ts b/cli/src/plugins/plugin-editorconfig/EditorConfigOutputPlugin.ts new file mode 100644 index 00000000..77e8b575 --- /dev/null +++ b/cli/src/plugins/plugin-editorconfig/EditorConfigOutputPlugin.ts @@ -0,0 +1,79 @@ +import type { + OutputPluginContext, + OutputWriteContext, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {FilePathKind} from '@truenine/plugin-shared' + +const EDITOR_CONFIG_FILE = '.editorconfig' + +/** + * Output plugin for writing .editorconfig files to project directories. + * Reads EditorConfig files collected by EditorConfigInputPlugin. + */ +export class EditorConfigOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('EditorConfigOutputPlugin') + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + const {editorConfigFiles} = ctx.collectedInputContext + + if (editorConfigFiles == null || editorConfigFiles.length === 0) return results + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + if (project.isPromptSourceProject === true) continue + + const filePath = this.joinPath(projectDir.path, EDITOR_CONFIG_FILE) + results.push({ + pathKind: FilePathKind.Relative, + path: filePath, + basePath: projectDir.basePath, + getDirectoryName: () => projectDir.getDirectoryName(), + getAbsolutePath: () => this.resolvePath(projectDir.basePath, filePath) + }) + } + + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {editorConfigFiles} = ctx.collectedInputContext + if (editorConfigFiles != null && editorConfigFiles.length > 0) return true + + this.log.debug('skipped', {reason: 'no EditorConfig files found'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + const {editorConfigFiles} = ctx.collectedInputContext + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + if (editorConfigFiles == null || editorConfigFiles.length === 0) return {files: fileResults, dirs: dirResults} + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + if (project.isPromptSourceProject === true) continue + + const projectName = project.name ?? 'unknown' + + for (const config of editorConfigFiles) { + const fullPath = this.resolvePath(projectDir.basePath, projectDir.path, EDITOR_CONFIG_FILE) + const result = await this.writeFile(ctx, fullPath, config.content, `project:${projectName}/.editorconfig`) + fileResults.push(result) + } + } + + return {files: fileResults, dirs: dirResults} + } +} diff --git a/cli/src/plugins/plugin-editorconfig/index.ts b/cli/src/plugins/plugin-editorconfig/index.ts new file mode 100644 index 00000000..189999e5 --- /dev/null +++ b/cli/src/plugins/plugin-editorconfig/index.ts @@ -0,0 +1,3 @@ +export { + EditorConfigOutputPlugin +} from './EditorConfigOutputPlugin' diff --git a/cli/src/plugins/plugin-gemini-cli/GeminiCLIOutputPlugin.ts b/cli/src/plugins/plugin-gemini-cli/GeminiCLIOutputPlugin.ts new file mode 100644 index 00000000..6f6828b4 --- /dev/null +++ b/cli/src/plugins/plugin-gemini-cli/GeminiCLIOutputPlugin.ts @@ -0,0 +1,16 @@ +import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' + +const PROJECT_MEMORY_FILE = 'GEMINI.md' +const GLOBAL_CONFIG_DIR = '.gemini' + +export class GeminiCLIOutputPlugin extends BaseCLIOutputPlugin { + constructor() { + super('GeminiCLIOutputPlugin', { + globalConfigDir: GLOBAL_CONFIG_DIR, + outputFileName: PROJECT_MEMORY_FILE, + supportsFastCommands: false, + supportsSubAgents: false, + supportsSkills: false + }) + } +} diff --git a/cli/src/plugins/plugin-gemini-cli/index.ts b/cli/src/plugins/plugin-gemini-cli/index.ts new file mode 100644 index 00000000..4a330a0d --- /dev/null +++ b/cli/src/plugins/plugin-gemini-cli/index.ts @@ -0,0 +1,3 @@ +export { + GeminiCLIOutputPlugin +} from './GeminiCLIOutputPlugin' diff --git a/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.test.ts b/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.test.ts new file mode 100644 index 00000000..4c0b1de6 --- /dev/null +++ b/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.test.ts @@ -0,0 +1,265 @@ +import * as fs from 'node:fs' +import {createLogger} from '@truenine/plugin-shared' +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {GitExcludeOutputPlugin} from './GitExcludeOutputPlugin' + +vi.mock('node:fs') + +const dirStat = {isDirectory: () => true, isFile: () => false} as any +const fileStat = {isDirectory: () => false, isFile: () => true} as any + +function setupFsMocks(existsFn: (p: string) => boolean, lstatFn?: (p: string) => any): void { + vi.mocked(fs.existsSync).mockImplementation((p: any) => existsFn(String(p))) + vi.mocked(fs.lstatSync).mockImplementation((p: any) => { + if (lstatFn) return lstatFn(String(p)) + return String(p).endsWith('.git') ? dirStat : fileStat // Default: .git is a directory + }) + vi.mocked(fs.readdirSync).mockReturnValue([] as any) // Default: empty dirs for findAllGitRepos scanning + vi.mocked(fs.readFileSync).mockReturnValue('') + vi.mocked(fs.writeFileSync).mockImplementation(() => {}) + vi.mocked(fs.mkdirSync).mockImplementation(() => '') +} + +describe('gitExcludeOutputPlugin', () => { + beforeEach(() => vi.clearAllMocks()) + + it('should write to git exclude in projects with merge', async () => { + const plugin = new GitExcludeOutputPlugin() + + const ctx = { + collectedInputContext: { + globalGitIgnore: 'dist/', + workspace: { + directory: {path: '/ws'}, + projects: [ + { + name: 'project1', + dirFromWorkspacePath: { + path: 'project1', + basePath: '/ws', + getAbsolutePath: () => '/ws/project1' + }, + isPromptSourceProject: false + } + ] + } + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as any + + setupFsMocks(p => p.includes('project1') && p.includes('.git')) + + const spy = vi.mocked(fs.writeFileSync) + const result = await plugin.writeProjectOutputs(ctx) + + expect(result.files.length).toBeGreaterThanOrEqual(1) + expect(spy).toHaveBeenCalled() + const firstCall = spy.mock.calls[0] + const writtenContent = (firstCall?.[1] ?? '') as string + expect(writtenContent).toBe('dist/\n') + }) + + it('should skip if no globalGitIgnore and no shadowGitExclude', async () => { + const plugin = new GitExcludeOutputPlugin() + const ctx = { + collectedInputContext: { + workspace: { + directory: {path: '/ws'}, + projects: [] + } + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as any + + const result = await plugin.writeProjectOutputs(ctx) + expect(result.files).toHaveLength(0) + }) + + it('should merge globalGitIgnore and shadowGitExclude', async () => { + const plugin = new GitExcludeOutputPlugin() + + const ctx = { + collectedInputContext: { + globalGitIgnore: 'node_modules/', + shadowGitExclude: '.idea/\n*.log', + workspace: { + directory: {path: '/ws'}, + projects: [ + { + name: 'project1', + dirFromWorkspacePath: { + path: 'project1', + basePath: '/ws', + getAbsolutePath: () => '/ws/project1' + }, + isPromptSourceProject: false + } + ] + } + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as any + + setupFsMocks(p => p.includes('.git')) + + const spy = vi.mocked(fs.writeFileSync) + await plugin.writeProjectOutputs(ctx) + + 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') + }) + + it('should replace existing managed section', async () => { + const plugin = new GitExcludeOutputPlugin() + + const ctx = { + collectedInputContext: { + globalGitIgnore: 'new-content/', + workspace: { + directory: {path: '/ws'}, + projects: [ + { + name: 'project1', + dirFromWorkspacePath: { + path: 'project1', + basePath: '/ws', + getAbsolutePath: () => '/ws/project1' + }, + isPromptSourceProject: false + } + ] + } + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as any + + setupFsMocks(p => p.includes('.git')) + + const spy = vi.mocked(fs.writeFileSync) + await plugin.writeProjectOutputs(ctx) + + const firstCall = spy.mock.calls[0] + const writtenContent = (firstCall?.[1] ?? '') as string + expect(writtenContent).toBe('new-content/\n') + }) + + it('should work with only shadowGitExclude', async () => { + const plugin = new GitExcludeOutputPlugin() + + const ctx = { + collectedInputContext: { + shadowGitExclude: '.cache/', + workspace: { + directory: {path: '/ws'}, + projects: [ + { + name: 'project1', + dirFromWorkspacePath: { + path: 'project1', + basePath: '/ws', + getAbsolutePath: () => '/ws/project1' + }, + isPromptSourceProject: false + } + ] + } + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as any + + setupFsMocks(p => p.includes('.git')) + + const spy = vi.mocked(fs.writeFileSync) + await plugin.writeProjectOutputs(ctx) + + const firstCall = spy.mock.calls[0] + const writtenContent = (firstCall?.[1] ?? '') as string + expect(writtenContent).toContain('.cache/') + }) + + it('should resolve submodule .git file with gitdir pointer', async () => { + const plugin = new GitExcludeOutputPlugin() + + const ctx = { + collectedInputContext: { + globalGitIgnore: '.kiro/', + workspace: { + directory: {path: '/ws'}, + projects: [ + { + name: 'submod', + dirFromWorkspacePath: { + path: 'submod', + basePath: '/ws', + getAbsolutePath: () => '/ws/submod' + }, + isPromptSourceProject: false + } + ] + } + } + } as any + + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const s = String(p).replaceAll('\\', '/') + return s === '/ws/submod/.git' || s === '/ws/.git' + }) + vi.mocked(fs.lstatSync).mockImplementation((p: any) => { + 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) => { + const s = String(p).replaceAll('\\', '/') + if (s === '/ws/submod/.git') return 'gitdir: ../.git/modules/submod' + return '' + }) + vi.mocked(fs.readdirSync).mockReturnValue([] as any) + 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 () => { + const plugin = new GitExcludeOutputPlugin() + + const ctx = { + collectedInputContext: { + globalGitIgnore: '.kiro/', + workspace: { + directory: {path: '/ws'}, + projects: [] + } + } + } as any + + const infoDirent = {name: 'info', isDirectory: () => true, isFile: () => false} as any + const modADirent = {name: 'modA', isDirectory: () => true, isFile: () => false} as any + const modBDirent = {name: 'modB', isDirectory: () => true, isFile: () => false} as any + + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + 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).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 + }) + 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/plugin-git-exclude/GitExcludeOutputPlugin.ts b/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.ts new file mode 100644 index 00000000..a8df3611 --- /dev/null +++ b/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.ts @@ -0,0 +1,275 @@ +import type { + OutputPluginContext, + OutputWriteContext, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import * as fs from 'node:fs' +import * as path from 'node:path' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {findAllGitRepos, findGitModuleInfoDirs, resolveGitInfoDir} from '@truenine/plugin-output-shared/utils' +import {FilePathKind} from '@truenine/plugin-shared' + +export class GitExcludeOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('GitExcludeOutputPlugin') + } + + 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 // Skip prompt source projects + + const projectDirPath = project.dirFromWorkspacePath + const projectDir = projectDirPath.getAbsolutePath() + const {basePath} = projectDirPath + const gitRepoDirs = [projectDir, ...findAllGitRepos(projectDir)] // project root + nested submodules/repos + + for (const repoDir of gitRepoDirs) { + const gitInfoDir = resolveGitInfoDir(repoDir) + if (gitInfoDir == null) continue + + const excludeFilePath = path.join(gitInfoDir, 'exclude') + const relExcludePath = path.relative(basePath, excludeFilePath) + + results.push({ + pathKind: FilePathKind.Relative, + path: relExcludePath, + basePath, + getDirectoryName: () => path.basename(repoDir), + getAbsolutePath: () => excludeFilePath + }) + } + } + + const wsDir = ctx.collectedInputContext.workspace.directory.path // Also register .git/modules/ exclude files + const wsDotGit = path.join(wsDir, '.git') + if (fs.existsSync(wsDotGit) && fs.lstatSync(wsDotGit).isDirectory()) { + for (const moduleInfoDir of findGitModuleInfoDirs(wsDotGit)) { + const excludeFilePath = path.join(moduleInfoDir, 'exclude') + const relExcludePath = path.relative(wsDir, excludeFilePath) + + results.push({ + pathKind: FilePathKind.Relative, + path: relExcludePath, + basePath: wsDir, + getDirectoryName: () => path.basename(path.dirname(moduleInfoDir)), + getAbsolutePath: () => excludeFilePath + }) + } + } + + return results + } + + async registerGlobalOutputDirs(): Promise { + return [] // No global directories to clean + } + + async registerGlobalOutputFiles(): Promise { + return [] // No global files to clean - workspace exclude is handled in writeProjectOutputs + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {globalGitIgnore, shadowGitExclude} = ctx.collectedInputContext + const hasContent = (globalGitIgnore != null && globalGitIgnore.length > 0) + || (shadowGitExclude != null && shadowGitExclude.length > 0) + + if (!hasContent) { + this.log.debug({action: 'canWrite', result: false, reason: 'No gitignore or exclude content found'}) + return false + } + + const {projects} = ctx.collectedInputContext.workspace + const hasGitProjects = projects.some(project => { + if (project.dirFromWorkspacePath == null) return false + const projectDir = project.dirFromWorkspacePath.getAbsolutePath() + if (resolveGitInfoDir(projectDir) != null) return true // Check project root + return findAllGitRepos(projectDir).some(d => resolveGitInfoDir(d) != null) // Check nested repos + }) + + const workspaceDir = ctx.collectedInputContext.workspace.directory.path + const hasWorkspaceGit = resolveGitInfoDir(workspaceDir) != null + + const canWrite = hasGitProjects || hasWorkspaceGit + this.log.debug({ + action: 'canWrite', + result: canWrite, + hasGitProjects, + hasWorkspaceGit, + reason: canWrite ? 'Found git repositories to update' : 'No git repositories found' + }) + + return canWrite + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const fileResults: WriteResult[] = [] + const {globalGitIgnore, shadowGitExclude} = ctx.collectedInputContext + + const managedContent = this.buildManagedContent(globalGitIgnore, shadowGitExclude) + + if (managedContent.length === 0) { + this.log.debug({action: 'write', message: 'No gitignore or exclude content found, skipping'}) + return {files: [], dirs: []} + } + + const {workspace} = ctx.collectedInputContext + const {projects} = workspace + const writtenPaths = new Set() // Track written paths to avoid duplicates + + for (const project of projects) { + if (project.dirFromWorkspacePath == null) continue + + const projectDir = project.dirFromWorkspacePath.getAbsolutePath() + const gitRepoDirs = [projectDir, ...findAllGitRepos(projectDir)] // project root + nested submodules/repos + + for (const repoDir of gitRepoDirs) { + const gitInfoDir = resolveGitInfoDir(repoDir) + if (gitInfoDir == null) continue + + const gitInfoExcludePath = path.join(gitInfoDir, 'exclude') + + if (writtenPaths.has(gitInfoExcludePath)) continue + writtenPaths.add(gitInfoExcludePath) + + const label = repoDir === projectDir + ? `project:${project.name ?? 'unknown'}` + : `nested:${path.relative(projectDir, repoDir)}` + + this.log.trace({action: 'write', path: gitInfoExcludePath, label}) + + const result = await this.writeGitExcludeFile(ctx, gitInfoExcludePath, managedContent, label) + fileResults.push(result) + } + } + + const workspaceDir = workspace.directory.path + const workspaceGitInfoDir = resolveGitInfoDir(workspaceDir) // workspace root .git (may also be submodule host) + + if (workspaceGitInfoDir != null) { + const workspaceGitExclude = path.join(workspaceGitInfoDir, 'exclude') + + if (!writtenPaths.has(workspaceGitExclude)) { + this.log.trace({action: 'write', path: workspaceGitExclude, target: 'workspace'}) + const result = await this.writeGitExcludeFile(ctx, workspaceGitExclude, managedContent, 'workspace') + fileResults.push(result) + writtenPaths.add(workspaceGitExclude) + } + } + + const workspaceNestedRepos = findAllGitRepos(workspaceDir) // nested repos under workspace root not covered by projects + for (const repoDir of workspaceNestedRepos) { + const gitInfoDir = resolveGitInfoDir(repoDir) + if (gitInfoDir == null) continue + + const excludePath = path.join(gitInfoDir, 'exclude') + if (writtenPaths.has(excludePath)) continue + writtenPaths.add(excludePath) + + const label = `workspace-nested:${path.relative(workspaceDir, repoDir)}` + this.log.trace({action: 'write', path: excludePath, label}) + + const result = await this.writeGitExcludeFile(ctx, excludePath, managedContent, label) + fileResults.push(result) + } + + const dotGitDir = path.join(workspaceDir, '.git') // Scan .git/modules/ for submodule info dirs + if (fs.existsSync(dotGitDir) && fs.lstatSync(dotGitDir).isDirectory()) { + for (const moduleInfoDir of findGitModuleInfoDirs(dotGitDir)) { + const excludePath = path.join(moduleInfoDir, 'exclude') + if (writtenPaths.has(excludePath)) continue + writtenPaths.add(excludePath) + + const label = `git-module:${path.relative(dotGitDir, moduleInfoDir)}` + this.log.trace({action: 'write', path: excludePath, label}) + + const result = await this.writeGitExcludeFile(ctx, excludePath, managedContent, label) + fileResults.push(result) + } + } + + return {files: fileResults, dirs: []} + } + + private buildManagedContent(globalGitIgnore?: string, shadowGitExclude?: string): string { + const parts: string[] = [] + + if (globalGitIgnore != null && globalGitIgnore.trim().length > 0) { // Handle globalGitIgnore first + const sanitized = this.sanitizeContent(globalGitIgnore) + if (sanitized.length > 0) parts.push(sanitized) + } + + if (shadowGitExclude != null && shadowGitExclude.trim().length > 0) { // Handle shadowGitExclude + const sanitized = this.sanitizeContent(shadowGitExclude) + if (sanitized.length > 0) parts.push(sanitized) + } + + if (parts.length === 0) return '' // Return early if no content was added + return parts.join('\n') + } + + private sanitizeContent(content: string): string { + const lines = content.split(/\r?\n/) + const filtered = lines.filter(line => { + const trimmed = line.trim() + if (trimmed.length === 0) return true + return !(trimmed.startsWith('#') && !trimmed.startsWith('\\#')) + }) + return filtered.join('\n').trim() + } + + private normalizeContent(content: string): string { + const trimmed = content.trim() + if (trimmed.length === 0) return '' + return `${trimmed}\n` + } + + private async writeGitExcludeFile( + ctx: OutputWriteContext, + filePath: string, + managedContent: string, + label: string + ): Promise { + const workspaceDir = ctx.collectedInputContext.workspace.directory.path // Create RelativePath for the result + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.relative(workspaceDir, filePath), + basePath: workspaceDir, + getDirectoryName: () => path.basename(path.dirname(filePath)), + getAbsolutePath: () => filePath + } + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'gitExclude', path: filePath, label}) + return {path: relativePath, success: true, skipped: false} + } + + try { + const gitInfoDir = path.dirname(filePath) // Ensure the .git/info directory exists + if (!fs.existsSync(gitInfoDir)) { + fs.mkdirSync(gitInfoDir, {recursive: true}) + this.log.debug({action: 'mkdir', path: gitInfoDir, message: 'Created .git/info directory'}) + } + + const finalContent = this.normalizeContent(managedContent) + + fs.writeFileSync(filePath, finalContent, 'utf8') // Write the exclude file + this.log.trace({action: 'write', type: 'gitExclude', path: filePath, label}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'gitExclude', path: filePath, label, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } +} diff --git a/cli/src/plugins/plugin-git-exclude/index.ts b/cli/src/plugins/plugin-git-exclude/index.ts new file mode 100644 index 00000000..b4de77a1 --- /dev/null +++ b/cli/src/plugins/plugin-git-exclude/index.ts @@ -0,0 +1,3 @@ +export { + GitExcludeOutputPlugin +} from './GitExcludeOutputPlugin' diff --git a/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.test.ts b/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.test.ts new file mode 100644 index 00000000..be4aee01 --- /dev/null +++ b/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.test.ts @@ -0,0 +1,309 @@ +import type {ILogger} from '@truenine/plugin-shared' +import {Buffer} from 'node:buffer' +import * as path from 'node:path' +import {PromptKind} from '@truenine/plugin-shared' +import {describe, expect, it, vi} from 'vitest' +import {SkillInputPlugin} from './SkillInputPlugin' + +describe('skillInputPlugin', () => { + const createMockLogger = (): ILogger => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + }) + + describe('readMcpConfig', () => { + const plugin = new SkillInputPlugin() + + it('should return undefined when mcp.json does not exist', () => { + const mockFs = { + existsSync: vi.fn().mockReturnValue(false), + statSync: vi.fn(), + readFileSync: vi.fn() + } as unknown as typeof import('node:fs') + + const result = plugin.readMcpConfig('/skill/dir', mockFs, createMockLogger()) + expect(result).toBeUndefined() + }) + + it('should parse valid mcp.json', () => { + const mcpContent = JSON.stringify({ + mcpServers: { + 'test-server': { + command: 'uvx', + args: ['test-package'], + env: {TEST: 'value'} + } + } + }) + + const mockFs = { + existsSync: vi.fn().mockReturnValue(true), + statSync: vi.fn().mockReturnValue({isFile: () => true}), + readFileSync: vi.fn().mockReturnValue(mcpContent) + } as unknown as typeof import('node:fs') + + const result = plugin.readMcpConfig('/skill/dir', mockFs, createMockLogger()) + + expect(result).toBeDefined() + expect(result?.type).toBe(PromptKind.SkillMcpConfig) + expect(result?.mcpServers['test-server']).toEqual({ + command: 'uvx', + args: ['test-package'], + env: {TEST: 'value'} + }) + }) + + it('should return undefined for invalid JSON', () => { + const mockFs = { + existsSync: vi.fn().mockReturnValue(true), + statSync: vi.fn().mockReturnValue({isFile: () => true}), + readFileSync: vi.fn().mockReturnValue('invalid json') + } as unknown as typeof import('node:fs') + + const logger = createMockLogger() + const result = plugin.readMcpConfig('/skill/dir', mockFs, logger) + + expect(result).toBeUndefined() + expect(logger.warn).toHaveBeenCalled() + }) + + it('should return undefined when mcpServers field is missing', () => { + const mockFs = { + existsSync: vi.fn().mockReturnValue(true), + statSync: vi.fn().mockReturnValue({isFile: () => true}), + readFileSync: vi.fn().mockReturnValue('{}') + } as unknown as typeof import('node:fs') + + const logger = createMockLogger() + const result = plugin.readMcpConfig('/skill/dir', mockFs, logger) + + expect(result).toBeUndefined() + expect(logger.warn).toHaveBeenCalled() + }) + }) + + describe('scanSkillDirectory', () => { + const plugin = new SkillInputPlugin() + + it('should scan child docs and resources at root level', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'skill.mdx', isFile: () => true, isDirectory: () => false}, + {name: 'guide.mdx', isFile: () => true, isDirectory: () => false}, + {name: 'mcp.json', isFile: () => true, isDirectory: () => false}, + {name: 'helper.kt', isFile: () => true, isDirectory: () => false}, + {name: 'logo.png', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockImplementation((filePath: string) => { + if (filePath.endsWith('.mdx')) return '# Content' + if (filePath.endsWith('.png')) return Buffer.from('binary') + return 'code content' + }) + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.childDocs).toHaveLength(1) // Should have 1 child doc (guide.mdx, not skill.mdx) + expect(result.childDocs[0]?.relativePath).toBe('guide.mdx') + expect(result.childDocs[0]?.type).toBe(PromptKind.SkillChildDoc) + + expect(result.resources).toHaveLength(2) // Should have 2 resources (helper.kt, logo.png, not mcp.json) + expect(result.resources.map(r => r.fileName)).toContain('helper.kt') + expect(result.resources.map(r => r.fileName)).toContain('logo.png') + }) + + it('should recursively scan subdirectories', () => { + const skillDir = path.normalize('/skill/dir') + const docsDir = path.join(skillDir, 'docs') + const assetsDir = path.join(skillDir, 'assets') + + const mockFs = { + readdirSync: vi.fn().mockImplementation((dir: string) => { + const normalizedDir = path.normalize(dir) + if (normalizedDir === skillDir) { + return [ + {name: 'docs', isFile: () => false, isDirectory: () => true}, + {name: 'assets', isFile: () => false, isDirectory: () => true} + ] + } + if (normalizedDir === docsDir) { + return [ + {name: 'guide.mdx', isFile: () => true, isDirectory: () => false}, + {name: 'api.mdx', isFile: () => true, isDirectory: () => false} + ] + } + if (normalizedDir === assetsDir) { + return [ + {name: 'logo.png', isFile: () => true, isDirectory: () => false}, + {name: 'schema.sql', isFile: () => true, isDirectory: () => false} + ] + } + return [] + }), + readFileSync: vi.fn().mockImplementation((filePath: string) => { + if (filePath.endsWith('.mdx')) return '# Content' + if (filePath.endsWith('.png')) return Buffer.from('binary') + return 'content' + }) + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory(skillDir, mockFs, createMockLogger()) + + expect(result.childDocs).toHaveLength(2) // Should have 2 child docs from docs/ + const childDocPaths = result.childDocs.map(d => d.relativePath.replaceAll('\\', '/')) // Normalize paths for cross-platform comparison + expect(childDocPaths).toContain('docs/guide.mdx') + expect(childDocPaths).toContain('docs/api.mdx') + + expect(result.resources).toHaveLength(2) // Should have 2 resources from assets/ + const resourcePaths = result.resources.map(r => r.relativePath.replaceAll('\\', '/')) + expect(resourcePaths).toContain('assets/logo.png') + expect(resourcePaths).toContain('assets/schema.sql') + }) + + it('should handle binary files with base64 encoding', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'image.png', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockReturnValue(Buffer.from('binary content')) + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.resources).toHaveLength(1) + expect(result.resources[0]?.encoding).toBe('base64') + expect(result.resources[0]?.category).toBe('image') + }) + + it('should handle text files with UTF-8 encoding', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'helper.kt', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockReturnValue('fun main() {}') + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.resources).toHaveLength(1) + expect(result.resources[0]?.encoding).toBe('text') + expect(result.resources[0]?.category).toBe('code') + expect(result.resources[0]?.content).toBe('fun main() {}') + }) + }) + + describe('.mdx to .md URL transformation in skills', () => { + const plugin = new SkillInputPlugin() + + it('should transform .mdx links to .md in child doc content', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockReturnValue('See [other doc](./other.mdx) for details') + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.childDocs).toHaveLength(1) + expect(result.childDocs[0]?.content).toContain('./other.md') + expect(result.childDocs[0]?.content).not.toContain('.mdx') + }) + + it('should transform .mdx links with anchors', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockReturnValue('[Section](./doc.mdx#section)') + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.childDocs[0]?.content).toContain('./doc.md#section') + }) + + it('should not transform external URLs', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockReturnValue('[External](https://example.com/file.mdx)') + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.childDocs[0]?.content).toContain('https://example.com/file.mdx') + }) + + it('should transform multiple .mdx links in same content', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockReturnValue('[First](./a.mdx) and [Second](./b.mdx)') + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.childDocs[0]?.content).toContain('./a.md') + expect(result.childDocs[0]?.content).toContain('./b.md') + expect(result.childDocs[0]?.content).not.toContain('.mdx') + }) + + it('should transform image references with .mdx extension', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockReturnValue('![Diagram](./diagram.mdx)') + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.childDocs[0]?.content).toContain('./diagram.md') + }) + + it('should preserve non-.mdx links unchanged', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockReturnValue('[Link](./file.md) and [Other](./doc.txt)') + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.childDocs[0]?.content).toContain('./file.md') + expect(result.childDocs[0]?.content).toContain('./doc.txt') + }) + + it('should transform .mdx in link text when it looks like a path', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockReturnValue('[example.mdx](./example.mdx)') + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.childDocs[0]?.content).toBe('[example.md](./example.md)') + }) + + it('should transform .mdx in link text for table markdown links', () => { + const mockFs = { + readdirSync: vi.fn().mockReturnValue([ + {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} + ]), + readFileSync: vi.fn().mockReturnValue('| [examples/example_figma.mdx](examples/example_figma.mdx) |') + } as unknown as typeof import('node:fs') + + const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) + + expect(result.childDocs[0]?.content).toBe('| [examples/example_figma.md](examples/example_figma.md) |') + }) + }) +}) diff --git a/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts b/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts new file mode 100644 index 00000000..b8427122 --- /dev/null +++ b/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts @@ -0,0 +1,476 @@ +import type {CollectedInputContext, ILogger, InputPluginContext, McpServerConfig, SkillChildDoc, SkillMcpConfig, SkillPrompt, SkillResource, SkillResourceCategory, SkillResourceEncoding, SkillYAMLFrontMatter} from '@truenine/plugin-shared' + +import {Buffer} from 'node:buffer' +import * as path from 'node:path' +import {mdxToMd} from '@truenine/md-compiler' +import {MetadataValidationError} from '@truenine/md-compiler/errors' +import {parseMarkdown, transformMdxReferencesToMd} from '@truenine/md-compiler/markdown' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' +import { + FilePathKind, + PromptKind, + SKILL_RESOURCE_BINARY_EXTENSIONS, + validateSkillMetadata +} from '@truenine/plugin-shared' + +function isBinaryResourceExtension(ext: string): boolean { + return (SKILL_RESOURCE_BINARY_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) +} + +function getResourceCategory(ext: string): SkillResourceCategory { + const lowerExt = ext.toLowerCase() + + const imageExtensions = [ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.ico', + '.bmp', + '.tiff', + '.svg' + ] + if (imageExtensions.includes(lowerExt)) return 'image' + + const codeExtensions = [ + '.kt', + '.java', + '.py', + '.pyi', + '.pyx', + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.go', + '.rs', + '.c', + '.cpp', + '.cc', + '.h', + '.hpp', + '.hxx', + '.cs', + '.fs', + '.fsx', + '.vb', + '.rb', + '.php', + '.swift', + '.scala', + '.groovy', + '.lua', + '.r', + '.jl', + '.ex', + '.exs', + '.erl', + '.clj', + '.cljs', + '.hs', + '.ml', + '.mli', + '.nim', + '.zig', + '.v', + '.dart', + '.vue', + '.svelte', + '.d.ts', + '.d.mts', + '.d.cts' + ] + if (codeExtensions.includes(lowerExt)) return 'code' + + const dataExtensions = [ + '.sql', + '.json', + '.jsonc', + '.json5', + '.xml', + '.xsd', + '.xsl', + '.xslt', + '.yaml', + '.yml', + '.toml', + '.csv', + '.tsv', + '.graphql', + '.gql', + '.proto' + ] + if (dataExtensions.includes(lowerExt)) return 'data' + + const documentExtensions = [ + '.txt', + '.text', + '.rtf', + '.log', + '.docx', + '.doc', + '.xlsx', + '.xls', + '.pptx', + '.ppt', + '.pdf', + '.odt', + '.ods', + '.odp' + ] + if (documentExtensions.includes(lowerExt)) return 'document' + + const configExtensions = [ + '.ini', + '.conf', + '.cfg', + '.config', + '.properties', + '.env', + '.envrc', + '.editorconfig', + '.gitignore', + '.gitattributes', + '.npmrc', + '.nvmrc', + '.npmignore', + '.eslintrc', + '.prettierrc', + '.stylelintrc', + '.babelrc', + '.browserslistrc' + ] + if (configExtensions.includes(lowerExt)) return 'config' + + const scriptExtensions = [ + '.sh', + '.bash', + '.zsh', + '.fish', + '.ps1', + '.psm1', + '.psd1', + '.bat', + '.cmd' + ] + if (scriptExtensions.includes(lowerExt)) return 'script' + + const binaryExtensions = [ + '.exe', + '.dll', + '.so', + '.dylib', + '.bin', + '.wasm', + '.class', + '.jar', + '.war', + '.pyd', + '.pyc', + '.pyo', + '.zip', + '.tar', + '.gz', + '.bz2', + '.7z', + '.rar', + '.ttf', + '.otf', + '.woff', + '.woff2', + '.eot', + '.db', + '.sqlite', + '.sqlite3' + ] + if (binaryExtensions.includes(lowerExt)) return 'binary' + + return 'other' +} + +function getMimeType(ext: string): string | void { + const mimeTypes: Record = { + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.js': 'text/javascript', + '.jsx': 'text/javascript', + '.json': 'application/json', + '.py': 'text/x-python', + '.java': 'text/x-java', + '.kt': 'text/x-kotlin', + '.go': 'text/x-go', + '.rs': 'text/x-rust', + '.c': 'text/x-c', + '.cpp': 'text/x-c++', + '.cs': 'text/x-csharp', + '.rb': 'text/x-ruby', + '.php': 'text/x-php', + '.swift': 'text/x-swift', + '.scala': 'text/x-scala', + '.sql': 'application/sql', + '.xml': 'application/xml', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.toml': 'text/toml', + '.csv': 'text/csv', + '.graphql': 'application/graphql', + '.txt': 'text/plain', + '.pdf': 'application/pdf', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.html': 'text/html', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.bmp': 'image/bmp' + } + return mimeTypes[ext.toLowerCase()] +} + +export class SkillInputPlugin extends AbstractInputPlugin { + constructor() { + super('SkillInputPlugin') + } + + readMcpConfig( + skillDir: string, + fs: typeof import('node:fs'), + logger: ILogger + ): SkillMcpConfig | void { + const mcpJsonPath = path.join(skillDir, 'mcp.json') + + if (!fs.existsSync(mcpJsonPath)) return void 0 + + if (!fs.statSync(mcpJsonPath).isFile()) { + logger.warn('mcp.json is not a file', {skillDir}) + return void 0 + } + + try { + const rawContent = fs.readFileSync(mcpJsonPath, 'utf8') + const parsed = JSON.parse(rawContent) as {mcpServers?: Record} + + if (parsed.mcpServers == null || typeof parsed.mcpServers !== 'object') { + logger.warn('mcp.json missing mcpServers field', {skillDir}) + return void 0 + } + + return { + type: PromptKind.SkillMcpConfig, + mcpServers: parsed.mcpServers, + rawContent + } + } + catch (e) { + logger.warn('failed to parse mcp.json', {skillDir, error: e}) + return void 0 + } + } + + scanSkillDirectory( + skillDir: string, + fs: typeof import('node:fs'), + logger: ILogger, + currentRelativePath: string = '' + ): {childDocs: SkillChildDoc[], resources: SkillResource[]} { + const childDocs: SkillChildDoc[] = [] + const resources: SkillResource[] = [] + + const currentDir = currentRelativePath + ? path.join(skillDir, currentRelativePath) + : skillDir + + try { + const entries = fs.readdirSync(currentDir, {withFileTypes: true}) + + for (const entry of entries) { + const relativePath = currentRelativePath + ? `${currentRelativePath}/${entry.name}` + : entry.name + + if (entry.isDirectory()) { + const subResult = this.scanSkillDirectory(skillDir, fs, logger, relativePath) + childDocs.push(...subResult.childDocs) + resources.push(...subResult.resources) + } else if (entry.isFile()) { + const filePath = path.join(currentDir, entry.name) + + if (entry.name.endsWith('.mdx')) { + if (currentRelativePath === '' && entry.name === 'skill.mdx') continue + + try { + const rawContent = fs.readFileSync(filePath, 'utf8') + const parsed = parseMarkdown(rawContent) + const content = transformMdxReferencesToMd(parsed.contentWithoutFrontMatter) + + childDocs.push({ + type: PromptKind.SkillChildDoc, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + markdownAst: parsed.markdownAst, + markdownContents: parsed.markdownContents, + ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, + relativePath, + dir: { + pathKind: FilePathKind.Relative, + path: relativePath, + basePath: skillDir, + getDirectoryName: () => path.dirname(relativePath), + getAbsolutePath: () => filePath + } + } as SkillChildDoc) + } + catch (e) { + logger.warn('failed to read child doc', {path: relativePath, error: e}) + } + } else { + if (currentRelativePath === '' && entry.name === 'mcp.json') continue + + const ext = path.extname(entry.name) + let content: string, + encoding: SkillResourceEncoding, + length: number + + try { + if (isBinaryResourceExtension(ext)) { + const buffer = fs.readFileSync(filePath) + content = buffer.toString('base64') + encoding = 'base64' + ;({length} = buffer) + } else { + content = fs.readFileSync(filePath, 'utf8') + encoding = 'text' + ;({length} = Buffer.from(content, 'utf8')) + } + + const mimeType = getMimeType(ext) + const resource: SkillResource = { + type: PromptKind.SkillResource, + extension: ext, + fileName: entry.name, + relativePath, + content, + encoding, + category: getResourceCategory(ext), + length + } + + if (mimeType != null) resources.push({...resource, mimeType}) + else resources.push(resource) + } + catch (e) { + logger.warn('failed to read resource file', {path: relativePath, error: e}) + } + } + } + } + } + catch (e) { + logger.warn('failed to scan directory', {path: currentDir, error: e}) + } + + return {childDocs, resources} + } + + async collect(ctx: InputPluginContext): Promise> { + const {userConfigOptions: options, logger, globalScope} = ctx + const {shadowProjectDir} = this.resolveBasePaths(options) + + const skillDir = this.resolveShadowPath(options.shadowSourceProject.skill.dist, shadowProjectDir) + + const skills: SkillPrompt[] = [] + if (!(ctx.fs.existsSync(skillDir) && ctx.fs.statSync(skillDir).isDirectory())) return {skills} + + const entries = ctx.fs.readdirSync(skillDir, {withFileTypes: true}) + for (const entry of entries) { + if (entry.isDirectory()) { + const skillFilePath = ctx.path.join(skillDir, entry.name, 'skill.mdx') + if (ctx.fs.existsSync(skillFilePath) && ctx.fs.statSync(skillFilePath).isFile()) { + try { + const rawContent = ctx.fs.readFileSync(skillFilePath, 'utf8') + + const parsed = parseMarkdown(rawContent) + + const compileResult = await mdxToMd(rawContent, { + globalScope, + extractMetadata: true, + basePath: ctx.path.join(skillDir, entry.name) + }) + + const mergedFrontMatter: SkillYAMLFrontMatter = { + ...parsed.yamlFrontMatter, + ...compileResult.metadata.fields + } as SkillYAMLFrontMatter + + const validationResult = validateSkillMetadata( + mergedFrontMatter as Record, + skillFilePath + ) + + for (const warning of validationResult.warnings) logger.debug(warning) + + if (!validationResult.valid) throw new MetadataValidationError(validationResult.errors, skillFilePath) + + const content = transformMdxReferencesToMd(compileResult.content) + + const skillAbsoluteDir = ctx.path.join(skillDir, entry.name) + + const mcpConfig = this.readMcpConfig(skillAbsoluteDir, ctx.fs, logger) + + const {childDocs, resources} = this.scanSkillDirectory( + skillAbsoluteDir, + ctx.fs, + logger + ) + + logger.debug('skill metadata extracted', { + skill: entry.name, + source: compileResult.metadata.source, + hasYaml: parsed.yamlFrontMatter != null, + hasExport: Object.keys(compileResult.metadata.fields).length > 0 + }) + + const {seriName} = mergedFrontMatter + + skills.push({ + type: PromptKind.Skill, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + yamlFrontMatter: mergedFrontMatter.name != null + ? mergedFrontMatter + : {name: entry.name, description: ''} as SkillYAMLFrontMatter, + ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, + markdownAst: parsed.markdownAst, + markdownContents: parsed.markdownContents, + ...mcpConfig != null && {mcpConfig}, + ...childDocs.length > 0 && {childDocs}, + ...resources.length > 0 && {resources}, + ...seriName != null && {seriName}, + dir: { + pathKind: FilePathKind.Relative, + path: entry.name, + basePath: skillDir, + getDirectoryName: () => entry.name, + getAbsolutePath: () => path.join(skillDir, entry.name) + } + }) + } + catch (e) { + logger.error('failed to parse skill', {file: skillFilePath, error: e}) + } + } + } + } + return {skills} + } +} diff --git a/cli/src/plugins/plugin-input-agentskills/index.ts b/cli/src/plugins/plugin-input-agentskills/index.ts new file mode 100644 index 00000000..25f6e244 --- /dev/null +++ b/cli/src/plugins/plugin-input-agentskills/index.ts @@ -0,0 +1,3 @@ +export { + SkillInputPlugin +} from './SkillInputPlugin' diff --git a/cli/src/plugins/plugin-input-editorconfig/EditorConfigInputPlugin.ts b/cli/src/plugins/plugin-input-editorconfig/EditorConfigInputPlugin.ts new file mode 100644 index 00000000..3c74eba8 --- /dev/null +++ b/cli/src/plugins/plugin-input-editorconfig/EditorConfigInputPlugin.ts @@ -0,0 +1,44 @@ +import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '@truenine/plugin-shared' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' +import {FilePathKind, IDEKind} from '@truenine/plugin-shared' + +function readIdeConfigFile( + type: T, + relativePath: string, + shadowProjectDir: string, + fs: typeof import('node:fs'), + path: typeof import('node:path') +): ProjectIDEConfigFile | undefined { + const absPath = path.join(shadowProjectDir, relativePath) + if (!(fs.existsSync(absPath) && fs.statSync(absPath).isFile())) return void 0 + + const content = fs.readFileSync(absPath, 'utf8') + return { + type, + content, + length: content.length, + filePathKind: FilePathKind.Absolute, + dir: { + pathKind: FilePathKind.Absolute, + path: absPath, + getDirectoryName: () => path.basename(absPath) + } + } +} + +export class EditorConfigInputPlugin extends AbstractInputPlugin { + constructor() { + super('EditorConfigInputPlugin') + } + + collect(ctx: InputPluginContext): Partial { + const {userConfigOptions, fs, path} = ctx + const {shadowProjectDir} = this.resolveBasePaths(userConfigOptions) + + const editorConfigFiles: ProjectIDEConfigFile[] = [] + const file = readIdeConfigFile(IDEKind.EditorConfig, '.editorconfig', shadowProjectDir, fs, path) + if (file != null) editorConfigFiles.push(file) + + return {editorConfigFiles} + } +} diff --git a/cli/src/plugins/plugin-input-editorconfig/index.ts b/cli/src/plugins/plugin-input-editorconfig/index.ts new file mode 100644 index 00000000..87495147 --- /dev/null +++ b/cli/src/plugins/plugin-input-editorconfig/index.ts @@ -0,0 +1,3 @@ +export { + EditorConfigInputPlugin +} from './EditorConfigInputPlugin' diff --git a/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.test.ts b/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.test.ts new file mode 100644 index 00000000..c223fd7e --- /dev/null +++ b/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.test.ts @@ -0,0 +1,131 @@ +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' +import {FastCommandInputPlugin} from './FastCommandInputPlugin' + +describe('fastCommandInputPlugin', () => { + describe('extractSeriesInfo', () => { + const plugin = new FastCommandInputPlugin() + + it('should derive series from parentDirName when provided', () => { + 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)) + + fc.assert( + fc.property( + alphanumericNoUnderscore, + alphanumericCommandName, + (parentDir, commandName) => { + const fileName = `${commandName}.mdx` + const result = plugin.extractSeriesInfo(fileName, parentDir) + + expect(result.series).toBe(parentDir) + expect(result.commandName).toBe(commandName) + } + ), + {numRuns: 100} + ) + }) + + it('should handle pe/compile.cn.mdx subdirectory format', () => { + const result = plugin.extractSeriesInfo('compile.cn.mdx', 'pe') + expect(result.series).toBe('pe') + expect(result.commandName).toBe('compile.cn') + }) + + it('should handle sk/skill-builder.cn.mdx subdirectory format', () => { + const result = plugin.extractSeriesInfo('skill-builder.cn.mdx', 'sk') + expect(result.series).toBe('sk') + expect(result.commandName).toBe('skill-builder.cn') + }) + + it('should extract series as substring before first underscore for filenames with underscore', () => { + const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z0-9]+$/i.test(s)) + + const alphanumericWithUnderscore = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^\w+$/.test(s)) + + fc.assert( + fc.property( + alphanumericNoUnderscore, + alphanumericWithUnderscore, + (seriesPrefix, commandName) => { + const fileName = `${seriesPrefix}_${commandName}.mdx` + const result = plugin.extractSeriesInfo(fileName) + + expect(result.series).toBe(seriesPrefix) + expect(result.commandName).toBe(commandName) + } + ), + {numRuns: 100} + ) + }) + + it('should return undefined series for filenames without underscore', () => { + const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z0-9]+$/i.test(s)) + + fc.assert( + fc.property( + alphanumericNoUnderscore, + baseName => { + const fileName = `${baseName}.mdx` + const result = plugin.extractSeriesInfo(fileName) + + expect(result.series).toBeUndefined() + expect(result.commandName).toBe(baseName) + } + ), + {numRuns: 100} + ) + }) + + it('should use only first underscore as delimiter', () => { + const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z0-9]+$/i.test(s)) + + fc.assert( + fc.property( + alphanumericNoUnderscore, + alphanumericNoUnderscore, + alphanumericNoUnderscore, + (seriesPrefix, part1, part2) => { + const fileName = `${seriesPrefix}_${part1}_${part2}.mdx` + const result = plugin.extractSeriesInfo(fileName) + + expect(result.series).toBe(seriesPrefix) + expect(result.commandName).toBe(`${part1}_${part2}`) + } + ), + {numRuns: 100} + ) + }) + + it('should handle pe_compile.mdx correctly', () => { + const result = plugin.extractSeriesInfo('pe_compile.mdx') + expect(result.series).toBe('pe') + expect(result.commandName).toBe('compile') + }) + + it('should handle compile.mdx correctly (no underscore)', () => { + const result = plugin.extractSeriesInfo('compile.mdx') + expect(result.series).toBeUndefined() + expect(result.commandName).toBe('compile') + }) + + it('should handle pe_compile_all.mdx correctly (multiple underscores)', () => { + const result = plugin.extractSeriesInfo('pe_compile_all.mdx') + expect(result.series).toBe('pe') + expect(result.commandName).toBe('compile_all') + }) + + it('should handle _compile.mdx correctly (empty prefix)', () => { + const result = plugin.extractSeriesInfo('_compile.mdx') + expect(result.series).toBe('') + expect(result.commandName).toBe('compile') + }) + }) +}) diff --git a/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts b/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts new file mode 100644 index 00000000..d9601fc9 --- /dev/null +++ b/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts @@ -0,0 +1,200 @@ +import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' +import type { + CollectedInputContext, + FastCommandPrompt, + FastCommandYAMLFrontMatter, + InputPluginContext, + MetadataValidationResult, + PluginOptions, + ResolvedBasePaths +} from '@truenine/plugin-shared' +import {mdxToMd} from '@truenine/md-compiler' +import {MetadataValidationError} from '@truenine/md-compiler/errors' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import {BaseDirectoryInputPlugin} from '@truenine/plugin-input-shared' +import { + FilePathKind, + PromptKind, + validateFastCommandMetadata +} from '@truenine/plugin-shared' + +export interface SeriesInfo { + readonly series?: string + readonly commandName: string +} + +export class FastCommandInputPlugin extends BaseDirectoryInputPlugin { + constructor() { + super('FastCommandInputPlugin', {configKey: 'shadowSourceProject.fastCommand.dist'}) + } + + protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + return this.resolveShadowPath(options.shadowSourceProject.fastCommand.dist, resolvedPaths.shadowProjectDir) + } + + protected validateMetadata(metadata: Record, filePath: string): MetadataValidationResult { + return validateFastCommandMetadata(metadata, filePath) + } + + protected createResult(items: FastCommandPrompt[]): Partial { + return {fastCommands: items} + } + + extractSeriesInfo(fileName: string, parentDirName?: string): SeriesInfo { + const baseName = fileName.replace(/\.mdx$/, '') + + if (parentDirName != null) { + return { + series: parentDirName, + commandName: baseName + } + } + + const underscoreIndex = baseName.indexOf('_') + + if (underscoreIndex === -1) return {commandName: baseName} + + return { + series: baseName.slice(0, Math.max(0, underscoreIndex)), + commandName: baseName.slice(Math.max(0, underscoreIndex + 1)) + } + } + + 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: FastCommandPrompt[] = [] + + 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.isFile() && entry.name.endsWith(this.extension)) { + const prompt = await this.processFile(entry.name, path.join(targetDir, entry.name), targetDir, void 0, ctx) + if (prompt != null) items.push(prompt) + } else 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 | undefined, + 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: parentDirName != null ? ctx.path.join(baseDir, parentDirName) : baseDir + }) + + const mergedFrontMatter: FastCommandYAMLFrontMatter | undefined = parsed.yamlFrontMatter != null || Object.keys(compileResult.metadata.fields).length > 0 + ? { + ...parsed.yamlFrontMatter, + ...compileResult.metadata.fields + } as FastCommandYAMLFrontMatter + : 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 != null ? `${parentDirName}/${fileName}` : 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 + } + } + + protected createPrompt( + entryName: string, + filePath: string, + content: string, + yamlFrontMatter: FastCommandYAMLFrontMatter | undefined, + rawFrontMatter: string | undefined, + parsed: ParsedMarkdown, + baseDir: string, + rawContent: string + ): FastCommandPrompt { + const slashIndex = entryName.indexOf('/') + const parentDirName = slashIndex !== -1 ? entryName.slice(0, slashIndex) : void 0 + const fileName = slashIndex !== -1 ? entryName.slice(slashIndex + 1) : entryName + + const seriesInfo = this.extractSeriesInfo(fileName, parentDirName) + const seriName = yamlFrontMatter?.seriName + + return { + type: PromptKind.FastCommand, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + ...yamlFrontMatter != null && {yamlFrontMatter}, + ...rawFrontMatter != null && {rawFrontMatter}, + markdownAst: parsed.markdownAst, + markdownContents: parsed.markdownContents, + dir: { + pathKind: FilePathKind.Relative, + path: entryName, + basePath: baseDir, + getDirectoryName: () => entryName.replace(/\.mdx$/, ''), + getAbsolutePath: () => filePath + }, + ...seriesInfo.series != null && {series: seriesInfo.series}, + commandName: seriesInfo.commandName, + ...seriName != null && {seriName}, + rawMdxContent: rawContent + } + } +} diff --git a/cli/src/plugins/plugin-input-fast-command/index.ts b/cli/src/plugins/plugin-input-fast-command/index.ts new file mode 100644 index 00000000..3bf19feb --- /dev/null +++ b/cli/src/plugins/plugin-input-fast-command/index.ts @@ -0,0 +1,6 @@ +export { + FastCommandInputPlugin +} from './FastCommandInputPlugin' +export type { + SeriesInfo +} from './FastCommandInputPlugin' diff --git a/cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.test.ts b/cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.test.ts new file mode 100644 index 00000000..26d24faa --- /dev/null +++ b/cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.test.ts @@ -0,0 +1,78 @@ +import type {InputPluginContext} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import {createLogger} from '@truenine/plugin-shared' +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {GitExcludeInputPlugin} from './GitExcludeInputPlugin' + +vi.mock('node:fs') + +const BASE_OPTIONS = { + workspaceDir: '/workspace', + shadowSourceProject: { + name: 'tnmsc-shadow', + skill: {src: 'src/skills', dist: 'dist/skills'}, + fastCommand: {src: 'src/commands', dist: 'dist/commands'}, + subAgent: {src: 'src/agents', dist: 'dist/agents'}, + rule: {src: 'src/rules', dist: 'dist/rules'}, + globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, + project: {src: 'app', dist: 'dist/app'} + }, + logLevel: 'debug' +} + +describe('gitExcludeInputPlugin', () => { + beforeEach(() => vi.clearAllMocks()) + + it('should collect exclude content from file if it exists', () => { + const plugin = new GitExcludeInputPlugin() + const ctx = { + logger: createLogger('test', 'debug'), + fs, + userConfigOptions: BASE_OPTIONS + } as unknown as InputPluginContext + + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('.idea/\n*.log') + + const result = plugin.collect(ctx) + + expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringMatching(/public[/\\]exclude/), 'utf8') + expect(result).toEqual({ + shadowGitExclude: '.idea/\n*.log' + }) + }) + + it('should return empty object if file does not exist', () => { + const plugin = new GitExcludeInputPlugin() + const ctx = { + logger: createLogger('test', 'debug'), + fs, + userConfigOptions: BASE_OPTIONS + } as unknown as InputPluginContext + + vi.mocked(fs.existsSync).mockReturnValue(false) + + const result = plugin.collect(ctx) + + expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/public[/\\]exclude/)) + expect(fs.readFileSync).not.toHaveBeenCalled() + expect(result).toEqual({}) + }) + + it('should return empty object if file is empty', () => { + const plugin = new GitExcludeInputPlugin() + const ctx = { + logger: createLogger('test', 'debug'), + fs, + userConfigOptions: BASE_OPTIONS + } as unknown as InputPluginContext + + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('') + + const result = plugin.collect(ctx) + + expect(result).toEqual({}) + }) +}) diff --git a/cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.ts b/cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.ts new file mode 100644 index 00000000..1f2560f9 --- /dev/null +++ b/cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.ts @@ -0,0 +1,23 @@ +import type {CollectedInputContext} from '@truenine/plugin-shared' +import * as path from 'node:path' +import {BaseFileInputPlugin} from '@truenine/plugin-input-shared' + +/** + * Input plugin that reads git exclude patterns from shadow source project. + * Reads from `public/exclude` file in the shadow project directory. + * + * This content will be merged with existing `.git/info/exclude` by GitExcludeOutputPlugin. + */ +export class GitExcludeInputPlugin extends BaseFileInputPlugin { + constructor() { + super('GitExcludeInputPlugin') + } + + protected getFilePath(shadowProjectDir: string): string { + return path.join(shadowProjectDir, 'public', 'exclude') + } + + protected getResultKey(): keyof CollectedInputContext { + return 'shadowGitExclude' + } +} diff --git a/cli/src/plugins/plugin-input-git-exclude/index.ts b/cli/src/plugins/plugin-input-git-exclude/index.ts new file mode 100644 index 00000000..7072ab18 --- /dev/null +++ b/cli/src/plugins/plugin-input-git-exclude/index.ts @@ -0,0 +1,3 @@ +export { + GitExcludeInputPlugin +} from './GitExcludeInputPlugin' diff --git a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts new file mode 100644 index 00000000..26304fcb --- /dev/null +++ b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts @@ -0,0 +1,66 @@ +import type {InputPluginContext} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as path from 'node:path' +import {createLogger} from '@truenine/plugin-shared' +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {GitIgnoreInputPlugin} from './GitIgnoreInputPlugin' + +vi.mock('node:fs') + +const BASE_OPTIONS = { + workspaceDir: '/workspace', + shadowSourceProject: { + name: 'tnmsc-shadow', + skill: {src: 'src/skills', dist: 'dist/skills'}, + fastCommand: {src: 'src/commands', dist: 'dist/commands'}, + subAgent: {src: 'src/agents', dist: 'dist/agents'}, + rule: {src: 'src/rules', dist: 'dist/rules'}, + globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, + project: {src: 'app', dist: 'dist/app'} + }, + logLevel: 'debug' +} + +describe('gitIgnoreInputPlugin', () => { + beforeEach(() => vi.clearAllMocks()) + + it('should collect gitignore content from file if it exists', () => { + const plugin = new GitIgnoreInputPlugin() + const ctx = { + logger: createLogger('test', 'debug'), + fs, + userConfigOptions: BASE_OPTIONS + } as unknown as InputPluginContext + + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('node_modules/\n.env') + + const result = plugin.collect(ctx) + + expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining(path.join('public', 'gitignore')), 'utf8') + expect(result).toEqual({ + globalGitIgnore: 'node_modules/\n.env' + }) + }) + + it('should fallback to template if file does not exist', () => { + const plugin = new GitIgnoreInputPlugin() + const ctx = { + logger: createLogger('test', 'debug'), + fs, + userConfigOptions: BASE_OPTIONS + } as unknown as InputPluginContext + + vi.mocked(fs.existsSync).mockReturnValue(false) + + const result = plugin.collect(ctx) + + expect(fs.existsSync).toHaveBeenCalledWith(expect.stringContaining(path.join('public', 'gitignore'))) + expect(fs.readFileSync).not.toHaveBeenCalled() + + if (result.globalGitIgnore != null && result.globalGitIgnore.length > 0) { // Plugin uses @truenine/init-bundle template as fallback — may or may not have content + expect(result).toHaveProperty('globalGitIgnore') + } else expect(result).toEqual({}) + }) +}) diff --git a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts new file mode 100644 index 00000000..ffb80a00 --- /dev/null +++ b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts @@ -0,0 +1,30 @@ +import type {CollectedInputContext} from '@truenine/plugin-shared' +import * as path from 'node:path' +import {bundles} from '@truenine/init-bundle' +import {BaseFileInputPlugin} from '@truenine/plugin-input-shared' + +type BundleMap = Readonly> +const bundleMap = bundles as unknown as BundleMap + +function getGitignoreTemplate(): string | undefined { // 从 bundles 获取 gitignore 模板内容(public/exclude) + return bundleMap['public/gitignore']?.content +} + +/** + * Input plugin that reads gitignore content from shadow source project. + * Falls back to template from init-bundle if file doesn't exist. + */ +export class GitIgnoreInputPlugin extends BaseFileInputPlugin { + constructor() { + const template = getGitignoreTemplate() + super('GitIgnoreInputPlugin', template != null ? {fallbackContent: template} : {}) + } + + protected getFilePath(shadowProjectDir: string): string { + return path.join(shadowProjectDir, 'public', 'gitignore') + } + + protected getResultKey(): keyof CollectedInputContext { + return 'globalGitIgnore' + } +} diff --git a/cli/src/plugins/plugin-input-gitignore/index.ts b/cli/src/plugins/plugin-input-gitignore/index.ts new file mode 100644 index 00000000..2a4ce65d --- /dev/null +++ b/cli/src/plugins/plugin-input-gitignore/index.ts @@ -0,0 +1,3 @@ +export { + GitIgnoreInputPlugin +} from './GitIgnoreInputPlugin' diff --git a/cli/src/plugins/plugin-input-global-memory/GlobalMemoryInputPlugin.ts b/cli/src/plugins/plugin-input-global-memory/GlobalMemoryInputPlugin.ts new file mode 100644 index 00000000..e4873da7 --- /dev/null +++ b/cli/src/plugins/plugin-input-global-memory/GlobalMemoryInputPlugin.ts @@ -0,0 +1,87 @@ +import type {CollectedInputContext, InputPluginContext} from '@truenine/plugin-shared' + +import * as os from 'node:os' +import process from 'node:process' + +import {mdxToMd} from '@truenine/md-compiler' +import {ScopeError} from '@truenine/md-compiler/errors' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' +import { + FilePathKind, + GlobalConfigDirectoryType, + PromptKind +} from '@truenine/plugin-shared' + +export class GlobalMemoryInputPlugin extends AbstractInputPlugin { + constructor() { + super('GlobalMemoryInputPlugin') + } + + async collect(ctx: InputPluginContext): Promise> { + const {userConfigOptions: options, fs, path, globalScope} = ctx + const {shadowProjectDir} = this.resolveBasePaths(options) + + const globalMemoryFile = this.resolveShadowPath(options.shadowSourceProject.globalMemory.dist, shadowProjectDir) + + if (!fs.existsSync(globalMemoryFile)) { + this.log.warn({action: 'collect', reason: 'fileNotFound', path: globalMemoryFile}) + return {} + } + + if (!fs.statSync(globalMemoryFile).isFile()) { + this.log.warn({action: 'collect', reason: 'notAFile', path: globalMemoryFile}) + return {} + } + + const rawContent = fs.readFileSync(globalMemoryFile, 'utf8') + const parsed = parseMarkdown(rawContent) + + let compiledContent: string // Only compile if globalScope is provided, otherwise use raw content // Compile MDX with globalScope to evaluate expressions like {profile.name} + if (globalScope != null) { + try { + compiledContent = await mdxToMd(rawContent, {globalScope, basePath: path.dirname(globalMemoryFile)}) + } + catch (e) { + if (e instanceof ScopeError) { + this.log.error(`MDX compilation failed: ${e.message}`) + this.log.error(`Please check your configuration file (~/.aindex/.tnmsc.json) and ensure all required variables are defined.`) + this.log.error(`For example, if using {profile.name}, add a "profile" section with "name" field to your config.`) + process.exit(1) + } + throw e + } + } else compiledContent = parsed.contentWithoutFrontMatter + + this.log.debug({action: 'collect', path: globalMemoryFile, contentLength: compiledContent.length}) + + return { + globalMemory: { + type: PromptKind.GlobalMemory, + content: compiledContent, + length: compiledContent.length, + filePathKind: FilePathKind.Relative, + ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, + markdownAst: parsed.markdownAst, + markdownContents: parsed.markdownContents, + dir: { + pathKind: FilePathKind.Relative, + path: path.basename(globalMemoryFile), + basePath: path.dirname(globalMemoryFile), + getDirectoryName: () => path.basename(globalMemoryFile), + getAbsolutePath: () => globalMemoryFile + }, + parentDirectoryPath: { + type: GlobalConfigDirectoryType.UserHome, + directory: { + pathKind: FilePathKind.Relative, + path: '', + basePath: os.homedir(), + getDirectoryName: () => path.basename(os.homedir()), + getAbsolutePath: () => os.homedir() + } + } + } + } + } +} diff --git a/cli/src/plugins/plugin-input-global-memory/index.ts b/cli/src/plugins/plugin-input-global-memory/index.ts new file mode 100644 index 00000000..963f6224 --- /dev/null +++ b/cli/src/plugins/plugin-input-global-memory/index.ts @@ -0,0 +1,3 @@ +export { + GlobalMemoryInputPlugin +} from './GlobalMemoryInputPlugin' diff --git a/cli/src/plugins/plugin-input-jetbrains-config/JetBrainsConfigInputPlugin.ts b/cli/src/plugins/plugin-input-jetbrains-config/JetBrainsConfigInputPlugin.ts new file mode 100644 index 00000000..2b3e25a0 --- /dev/null +++ b/cli/src/plugins/plugin-input-jetbrains-config/JetBrainsConfigInputPlugin.ts @@ -0,0 +1,52 @@ +import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '@truenine/plugin-shared' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' +import {FilePathKind, IDEKind} from '@truenine/plugin-shared' + +function readIdeConfigFile( + type: T, + relativePath: string, + shadowProjectDir: string, + fs: typeof import('node:fs'), + path: typeof import('node:path') +): ProjectIDEConfigFile | undefined { + const absPath = path.join(shadowProjectDir, relativePath) + if (!(fs.existsSync(absPath) && fs.statSync(absPath).isFile())) return void 0 + + const content = fs.readFileSync(absPath, 'utf8') + return { + type, + content, + length: content.length, + filePathKind: FilePathKind.Absolute, + dir: { + pathKind: FilePathKind.Absolute, + path: absPath, + getDirectoryName: () => path.basename(absPath) + } + } +} + +export class JetBrainsConfigInputPlugin extends AbstractInputPlugin { + constructor() { + super('JetBrainsConfigInputPlugin') + } + + collect(ctx: InputPluginContext): Partial { + const {userConfigOptions, fs, path} = ctx + const {shadowProjectDir} = this.resolveBasePaths(userConfigOptions) + + const files = [ + '.idea/codeStyles/Project.xml', + '.idea/codeStyles/codeStyleConfig.xml', + '.idea/.gitignore' + ] + const jetbrainsConfigFiles: ProjectIDEConfigFile[] = [] + + for (const relativePath of files) { + const file = readIdeConfigFile(IDEKind.IntellijIDEA, relativePath, shadowProjectDir, fs, path) + if (file != null) jetbrainsConfigFiles.push(file) + } + + return {jetbrainsConfigFiles} + } +} diff --git a/cli/src/plugins/plugin-input-jetbrains-config/index.ts b/cli/src/plugins/plugin-input-jetbrains-config/index.ts new file mode 100644 index 00000000..aab7350c --- /dev/null +++ b/cli/src/plugins/plugin-input-jetbrains-config/index.ts @@ -0,0 +1,3 @@ +export { + JetBrainsConfigInputPlugin +} from './JetBrainsConfigInputPlugin' diff --git a/cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.property.test.ts b/cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.property.test.ts new file mode 100644 index 00000000..b7cf2497 --- /dev/null +++ b/cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.property.test.ts @@ -0,0 +1,311 @@ +import type {InputEffectContext} from '@truenine/plugin-input-shared' +import type {ILogger, PluginOptions} from '@truenine/plugin-shared' + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import * as fc from 'fast-check' +import * as glob from 'fast-glob' +import {describe, expect, it} from 'vitest' +import {MarkdownWhitespaceCleanupEffectInputPlugin} from './MarkdownWhitespaceCleanupEffectInputPlugin' + +/** + * Feature: effect-input-plugins + * Property-based tests for MarkdownWhitespaceCleanupEffectInputPlugin + * + * Property 8: Trailing whitespace removal + * For any .md file processed by MarkdownWhitespaceCleanupEffectInputPlugin, + * no line in the output should end with space or tab characters. + * + * Property 9: Excessive blank line reduction + * For any .md file processed by MarkdownWhitespaceCleanupEffectInputPlugin, + * the output should contain at most 2 consecutive blank lines. + * + * Property 11: Line ending preservation + * For any .md file processed by MarkdownWhitespaceCleanupEffectInputPlugin, + * the line ending style (LF or CRLF) should be preserved in the output. + * + * Validates: Requirements 3.2, 3.3, 3.7 + */ + +function createMockLogger(): ILogger { // Test helpers + return { + trace: () => { }, + debug: () => { }, + info: () => { }, + warn: () => { }, + error: () => { }, + fatal: () => { }, + child: () => createMockLogger() + } as unknown as ILogger +} + +function createEffectContext(workspaceDir: string, shadowProjectDir: string, dryRun: boolean = false): InputEffectContext { + return { + logger: createMockLogger(), + fs, + path, + glob, + userConfigOptions: {} as PluginOptions, + workspaceDir, + shadowProjectDir, + dryRun + } +} // Generators + +const lineContentGen = fc.string({minLength: 0, maxLength: 100, unit: 'grapheme-ascii'}) // Generate a line of text (without line endings) + .filter(s => !s.includes('\n') && !s.includes('\r')) + +const trailingWhitespaceGen = fc.array( // Generate trailing whitespace (spaces and tabs) + fc.constantFrom(' ', '\t'), + {minLength: 0, maxLength: 10} +).map(chars => chars.join('')) + +const lineWithTrailingWhitespaceGen = fc.tuple(lineContentGen, trailingWhitespaceGen) // Generate a line with optional trailing whitespace + .map(([content, trailing]) => content + trailing) + +const markdownContentGen = fc.array(lineWithTrailingWhitespaceGen, {minLength: 1, maxLength: 20}) // Generate markdown content with various whitespace patterns + .chain(lines => + fc.array( // Randomly insert extra blank lines between content lines + fc.tuple( + fc.constant(null as string | null), + fc.integer({min: 0, max: 5}) // Number of blank lines to insert + ), + {minLength: lines.length, maxLength: lines.length} + ).map(blankCounts => { + const result: string[] = [] + for (let i = 0; i < lines.length; i++) { + const blankCount = blankCounts[i]?.[1] ?? 0 // Add blank lines before this line + for (let j = 0; j < blankCount; j++) result.push('') + result.push(lines[i]!) + } + return result + })) + +const lineEndingGen = fc.constantFrom('\n', '\r\n') // Generate line ending style + +const markdownWithLineEndingGen = fc.tuple(markdownContentGen, lineEndingGen) // Generate complete markdown content with specific line ending + .map(([lines, lineEnding]) => lines.join(lineEnding)) + +describe('markdownWhitespaceCleanupEffectInputPlugin Property Tests', () => { + describe('property 8: Trailing whitespace removal', () => { + it('should remove all trailing whitespace from every line', async () => { + const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() + + await fc.assert( + fc.asyncProperty( + markdownWithLineEndingGen, + async content => { + const cleaned = plugin.cleanMarkdownContent(content) // Process the content + + const lines = cleaned.split(/\r?\n/) // Split into lines (handle both LF and CRLF) + + for (const line of lines) expect(line).not.toMatch(/[ \t]$/) // Verify: No line should end with space or tab + } + ), + {numRuns: 100} + ) + }) + + it('should remove trailing whitespace in actual files', async () => { + await fc.assert( + fc.asyncProperty( + markdownWithLineEndingGen, + async content => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'whitespace-p8-')) // Create isolated temp directory for this property run + + try { + const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project with markdown file + const srcDir = path.join(shadowProjectDir, 'src') + + fs.mkdirSync(srcDir, {recursive: true}) + + const mdFilePath = path.join(srcDir, 'test.md') + fs.writeFileSync(mdFilePath, content, 'utf8') + + const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() // Execute plugin + const ctx = createEffectContext(tempDir, shadowProjectDir, false) + const effectMethod = (plugin as any).cleanupWhitespace.bind(plugin) + await effectMethod(ctx) + + const processedContent = fs.readFileSync(mdFilePath, 'utf8') // Read the processed file + const lines = processedContent.split(/\r?\n/) + + for (const line of lines) expect(line).not.toMatch(/[ \t]$/) // Verify: No line should end with space or tab + } + finally { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup + } + } + ), + {numRuns: 100} + ) + }, 120000) + }) + + describe('property 9: Excessive blank line reduction', () => { + it('should reduce consecutive blank lines to at most 2', async () => { + const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() + + await fc.assert( + fc.asyncProperty( + markdownWithLineEndingGen, + async content => { + const cleaned = plugin.cleanMarkdownContent(content) // Process the content + + const lines = cleaned.split(/\r?\n/) // Split into lines (handle both LF and CRLF) + + let maxConsecutiveBlank = 0 // Count consecutive blank lines + let currentConsecutiveBlank = 0 + + for (const line of lines) { + if (line === '') { + currentConsecutiveBlank++ + maxConsecutiveBlank = Math.max(maxConsecutiveBlank, currentConsecutiveBlank) + } else currentConsecutiveBlank = 0 + } + + expect(maxConsecutiveBlank).toBeLessThanOrEqual(2) // Verify: At most 2 consecutive blank lines + } + ), + {numRuns: 100} + ) + }) + + it('should reduce excessive blank lines in actual files', async () => { + await fc.assert( + fc.asyncProperty( + markdownWithLineEndingGen, + async content => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'whitespace-p9-')) // Create isolated temp directory for this property run + + try { + const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project with markdown file + const srcDir = path.join(shadowProjectDir, 'src') + + fs.mkdirSync(srcDir, {recursive: true}) + + const mdFilePath = path.join(srcDir, 'test.md') + fs.writeFileSync(mdFilePath, content, 'utf8') + + const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() // Execute plugin + const ctx = createEffectContext(tempDir, shadowProjectDir, false) + const effectMethod = (plugin as any).cleanupWhitespace.bind(plugin) + await effectMethod(ctx) + + const processedContent = fs.readFileSync(mdFilePath, 'utf8') // Read the processed file + const lines = processedContent.split(/\r?\n/) + + let maxConsecutiveBlank = 0 // Count consecutive blank lines + let currentConsecutiveBlank = 0 + + for (const line of lines) { + if (line === '') { + currentConsecutiveBlank++ + maxConsecutiveBlank = Math.max(maxConsecutiveBlank, currentConsecutiveBlank) + } else currentConsecutiveBlank = 0 + } + + expect(maxConsecutiveBlank).toBeLessThanOrEqual(2) // Verify: At most 2 consecutive blank lines + } + finally { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup + } + } + ), + {numRuns: 100} + ) + }) + }) + + describe('property 11: Line ending preservation', () => { + it('should preserve LF line endings', async () => { + const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() + + await fc.assert( + fc.asyncProperty( + markdownContentGen, + async lines => { + const content = lines.join('\n') // Create content with LF line endings + + const cleaned = plugin.cleanMarkdownContent(content) // Process the content + + expect(cleaned).not.toContain('\r\n') // Verify: Should not contain CRLF + + if (lines.length > 1) expect(cleaned).toContain('\n') // Verify: If multi-line, should contain LF + } + ), + {numRuns: 100} + ) + }) + + it('should preserve CRLF line endings', async () => { + const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() + + await fc.assert( + fc.asyncProperty( + markdownContentGen, + async lines => { + const content = lines.join('\r\n') // Create content with CRLF line endings + + const cleaned = plugin.cleanMarkdownContent(content) // Process the content + + if (lines.length <= 1) return // Verify: If multi-line, should use CRLF + + const crlfCount = (cleaned.match(/\r\n/g) ?? []).length + const lfOnlyCount = (cleaned.replaceAll('\r\n', '').match(/\n/g) ?? []).length + expect(lfOnlyCount).toBe(0) + expect(crlfCount).toBeGreaterThan(0) + } + ), + {numRuns: 100} + ) + }) + + it('should preserve line endings in actual files', async () => { + await fc.assert( + fc.asyncProperty( + markdownContentGen, + lineEndingGen, + async (lines, lineEnding) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'whitespace-p11-')) // Create isolated temp directory for this property run + + try { + const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project with markdown file + const srcDir = path.join(shadowProjectDir, 'src') + + fs.mkdirSync(srcDir, {recursive: true}) + + const content = lines.join(lineEnding) // Create content with specific line ending + const mdFilePath = path.join(srcDir, 'test.md') + fs.writeFileSync(mdFilePath, content, 'utf8') + + const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() // Execute plugin + const ctx = createEffectContext(tempDir, shadowProjectDir, false) + const effectMethod = (plugin as any).cleanupWhitespace.bind(plugin) + await effectMethod(ctx) + + const processedContent = fs.readFileSync(mdFilePath, 'utf8') // Read the processed file + + if (lines.length > 1) { // Verify line ending preservation + if (lineEnding === '\r\n') { + const crlfCount = (processedContent.match(/\r\n/g) ?? []).length // Should use CRLF + const lfOnlyCount = (processedContent.replaceAll('\r\n', '').match(/\n/g) ?? []).length + expect(lfOnlyCount).toBe(0) + expect(crlfCount).toBeGreaterThan(0) + } else { + expect(processedContent).not.toContain('\r\n') // Should use LF (no CRLF) + expect(processedContent).toContain('\n') + } + } + } + finally { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup + } + } + ), + {numRuns: 100} + ) + }) + }) +}) diff --git a/cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts b/cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts new file mode 100644 index 00000000..e2d40cd3 --- /dev/null +++ b/cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts @@ -0,0 +1,153 @@ +import type { + CollectedInputContext, + InputEffectContext, + InputEffectResult, + InputPluginContext +} from '@truenine/plugin-shared' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' + +/** + * Result of the markdown whitespace cleanup effect. + */ +export interface WhitespaceCleanupEffectResult extends InputEffectResult { + readonly modifiedFiles: string[] + readonly skippedFiles: string[] +} + +export class MarkdownWhitespaceCleanupEffectInputPlugin extends AbstractInputPlugin { + constructor() { + super('MarkdownWhitespaceCleanupEffectInputPlugin') + this.registerEffect('markdown-whitespace-cleanup', this.cleanupWhitespace.bind(this), 30) + } + + private async cleanupWhitespace(ctx: InputEffectContext): Promise { + const {fs, path, shadowProjectDir, dryRun, logger} = ctx + + const modifiedFiles: string[] = [] + const skippedFiles: string[] = [] + const errors: {path: string, error: Error}[] = [] + + const dirsToScan = [ + path.join(shadowProjectDir, 'src'), + path.join(shadowProjectDir, 'app'), + path.join(shadowProjectDir, 'dist') + ] + + for (const dir of dirsToScan) { + if (!fs.existsSync(dir)) { + logger.debug({action: 'whitespace-cleanup', message: 'Directory does not exist, skipping', dir}) + continue + } + + this.processDirectory(ctx, dir, modifiedFiles, skippedFiles, errors, dryRun ?? false) + } + + const hasErrors = errors.length > 0 + if (hasErrors) logger.warn({action: 'whitespace-cleanup', errors: errors.map(e => ({path: e.path, error: e.error.message}))}) + + return { + success: !hasErrors, + description: dryRun + ? `Would modify ${modifiedFiles.length} files, skip ${skippedFiles.length} files` + : `Modified ${modifiedFiles.length} files, skipped ${skippedFiles.length} files`, + modifiedFiles, + skippedFiles, + ...hasErrors && {error: new Error(`${errors.length} errors occurred during cleanup`)} + } + } + + private processDirectory( + ctx: InputEffectContext, + dir: string, + modifiedFiles: string[], + skippedFiles: string[], + errors: {path: string, error: Error}[], + dryRun: boolean + ): void { + const {fs, path, logger} = ctx + + let entries: import('node:fs').Dirent[] + try { + entries = fs.readdirSync(dir, {withFileTypes: true}) + } + catch (error) { + errors.push({path: dir, error: error as Error}) + logger.warn({action: 'whitespace-cleanup', message: 'Failed to read directory', path: dir, error: (error as Error).message}) + return + } + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name) + + if (entry.isDirectory()) this.processDirectory(ctx, entryPath, modifiedFiles, skippedFiles, errors, dryRun) + else if (entry.isFile() && entry.name.endsWith('.md')) this.processMarkdownFile(ctx, entryPath, modifiedFiles, skippedFiles, errors, dryRun) + } + } + + private processMarkdownFile( + ctx: InputEffectContext, + filePath: string, + modifiedFiles: string[], + skippedFiles: string[], + errors: {path: string, error: Error}[], + dryRun: boolean + ): void { + const {fs, logger} = ctx + + try { + const originalContent = fs.readFileSync(filePath, 'utf8') + const cleanedContent = this.cleanMarkdownContent(originalContent) + + if (originalContent === cleanedContent) { + skippedFiles.push(filePath) + logger.debug({action: 'whitespace-cleanup', skipped: filePath, reason: 'no changes needed'}) + return + } + + if (dryRun) { + logger.debug({action: 'whitespace-cleanup', dryRun: true, wouldModify: filePath}) + modifiedFiles.push(filePath) + } else { + fs.writeFileSync(filePath, cleanedContent, 'utf8') + modifiedFiles.push(filePath) + logger.debug({action: 'whitespace-cleanup', modified: filePath}) + } + } + catch (error) { + errors.push({path: filePath, error: error as Error}) + logger.warn({action: 'whitespace-cleanup', message: 'Failed to process file', path: filePath, error: (error as Error).message}) + } + } + + cleanMarkdownContent(content: string): string { + const lineEnding = this.detectLineEnding(content) + + const lines = content.split(/\r?\n/) + + const trimmedLines = lines.map(line => line.replace(/[ \t]+$/, '')) + + const result: string[] = [] + let consecutiveBlankCount = 0 + + for (const line of trimmedLines) { + if (line === '') { + consecutiveBlankCount++ + if (consecutiveBlankCount <= 2) result.push(line) + } else { + consecutiveBlankCount = 0 + result.push(line) + } + } + + return result.join(lineEnding) + } + + detectLineEnding(content: string): '\r\n' | '\n' { + if (content.includes('\r\n')) return '\r\n' + return '\n' + } + + collect(_ctx: InputPluginContext): Partial { + return {} + } +} diff --git a/cli/src/plugins/plugin-input-md-cleanup-effect/index.ts b/cli/src/plugins/plugin-input-md-cleanup-effect/index.ts new file mode 100644 index 00000000..1ec6b1bc --- /dev/null +++ b/cli/src/plugins/plugin-input-md-cleanup-effect/index.ts @@ -0,0 +1,6 @@ +export { + MarkdownWhitespaceCleanupEffectInputPlugin +} from './MarkdownWhitespaceCleanupEffectInputPlugin' +export type { + WhitespaceCleanupEffectResult +} from './MarkdownWhitespaceCleanupEffectInputPlugin' diff --git a/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.property.test.ts b/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.property.test.ts new file mode 100644 index 00000000..9b5551d2 --- /dev/null +++ b/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.property.test.ts @@ -0,0 +1,263 @@ +import type {InputEffectContext} from '@truenine/plugin-input-shared' +import type {ILogger, PluginOptions} from '@truenine/plugin-shared' + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import * as fc from 'fast-check' +import * as glob from 'fast-glob' +import {describe, expect, it} from 'vitest' +import {OrphanFileCleanupEffectInputPlugin} from './OrphanFileCleanupEffectInputPlugin' + +/** + * Feature: effect-input-plugins + * Property-based tests for OrphanFileCleanupEffectInputPlugin + * + * Property 5: Orphan .mdx file deletion + * For any .mdx file in dist/skills/, dist/commands/, dist/agents/, or dist/app/, + * if no corresponding source file exists according to the mapping rules, + * the file should be deleted after OrphanFileCleanupEffectInputPlugin executes. + * + * Property 7: Empty directory cleanup + * For any directory in dist/ that becomes empty after orphan file deletion, + * the directory should be removed by OrphanFileCleanupEffectInputPlugin. + * + * Validates: Requirements 2.2, 2.3, 2.4, 2.5, 2.7 + */ + +function createMockLogger(): ILogger { // Test helpers + return { + trace: () => { }, + debug: () => { }, + info: () => { }, + warn: () => { }, + error: () => { }, + fatal: () => { }, + child: () => createMockLogger() + } as unknown as ILogger +} + +function createEffectContext(workspaceDir: string, shadowProjectDir: string, dryRun: boolean = false): InputEffectContext { + return { + logger: createMockLogger(), + fs, + path, + glob, + userConfigOptions: {} as PluginOptions, + workspaceDir, + shadowProjectDir, + dryRun + } +} + +const validNameGen = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generators + .filter(s => /^[\w-]+$/.test(s)) + .map(s => s.toLowerCase()) + +const dirTypeGen = fc.constantFrom('skills', 'commands', 'agents', 'app') + +interface DistFile { // Generate a dist file structure with orphan and valid files + name: string + dirType: 'skills' | 'commands' | 'agents' | 'app' + hasSource: boolean +} + +const distFileGen: fc.Arbitrary = fc.record({name: validNameGen, dirType: dirTypeGen, hasSource: fc.boolean()}) + +describe('orphanFileCleanupEffectInputPlugin Property Tests', () => { + describe('property 5: Orphan .mdx file deletion', () => { + it('should delete orphan .mdx files and keep files with valid sources', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(distFileGen, {minLength: 1, maxLength: 10}) + .map(files => { + const seen = new Set() // Deduplicate by (name, dirType) to avoid conflicts + return files.filter(f => { + const key = `${f.dirType}:${f.name}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + }) + .filter(files => files.length > 0), + async distFiles => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orphan-cleanup-p5-')) // Create isolated temp directory for this property run + + try { + const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project structure + const distDir = path.join(shadowProjectDir, 'dist') + const srcDir = path.join(shadowProjectDir, 'src') + const appDir = path.join(shadowProjectDir, 'app') + + fs.mkdirSync(distDir, {recursive: true}) // Create directories + fs.mkdirSync(srcDir, {recursive: true}) + fs.mkdirSync(appDir, {recursive: true}) + + const expectedDeleted: string[] = [] // Track expected outcomes + const expectedKept: string[] = [] + + for (const file of distFiles) { // Create dist files and optionally their sources + const distTypePath = path.join(distDir, file.dirType) + fs.mkdirSync(distTypePath, {recursive: true}) + + const distFilePath = path.join(distTypePath, `${file.name}.mdx`) + fs.writeFileSync(distFilePath, `# ${file.name}`, 'utf8') + + if (file.hasSource) { + createSourceFile(shadowProjectDir, file.dirType, file.name) // Create corresponding source file + expectedKept.push(distFilePath) + } else expectedDeleted.push(distFilePath) + } + + const plugin = new OrphanFileCleanupEffectInputPlugin() // Execute plugin + const ctx = createEffectContext(tempDir, shadowProjectDir, false) + const effectMethod = (plugin as any).cleanupOrphanFiles.bind(plugin) + const result = await effectMethod(ctx) + + for (const filePath of expectedDeleted) { // Verify: Orphan files should be deleted + expect(fs.existsSync(filePath)).toBe(false) + expect(result.deletedFiles).toContain(filePath) + } + + for (const filePath of expectedKept) { // Verify: Files with sources should be kept + expect(fs.existsSync(filePath)).toBe(true) + expect(result.deletedFiles).not.toContain(filePath) + } + } + finally { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup + } + } + ), + {numRuns: 100} + ) + }, 120000) + }) + + describe('property 7: Empty directory cleanup', () => { + it('should remove directories that become empty after orphan deletion', async () => { + await fc.assert( + fc.asyncProperty( + validNameGen, + dirTypeGen, + async (name, dirType) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orphan-cleanup-p7-')) // Create isolated temp directory for this property run + + try { + const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project with orphan file in subdirectory + const distDir = path.join(shadowProjectDir, 'dist') + const distTypeDir = path.join(distDir, dirType) + const subDir = path.join(distTypeDir, 'subdir') + + fs.mkdirSync(subDir, {recursive: true}) + + const orphanFilePath = path.join(subDir, `${name}.mdx`) // Create orphan file in subdirectory (no source) + fs.writeFileSync(orphanFilePath, `# ${name}`, 'utf8') + + expect(fs.existsSync(subDir)).toBe(true) // Verify setup: subdirectory exists with file + expect(fs.existsSync(orphanFilePath)).toBe(true) + + const plugin = new OrphanFileCleanupEffectInputPlugin() // Execute plugin + const ctx = createEffectContext(tempDir, shadowProjectDir, false) + const effectMethod = (plugin as any).cleanupOrphanFiles.bind(plugin) + const result = await effectMethod(ctx) + + expect(fs.existsSync(orphanFilePath)).toBe(false) // Verify: Orphan file should be deleted + expect(result.deletedFiles).toContain(orphanFilePath) + + expect(fs.existsSync(subDir)).toBe(false) // Verify: Empty subdirectory should be removed + expect(result.deletedDirs).toContain(subDir) + } + finally { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup + } + } + ), + {numRuns: 100} + ) + }) + + it('should not remove directories that still contain files', async () => { + await fc.assert( + fc.asyncProperty( + validNameGen, + validNameGen, + async (orphanName, validName) => { + if (orphanName === validName) return // Ensure different names + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orphan-cleanup-p7b-')) // Create isolated temp directory for this property run + + try { + const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project with both orphan and valid files + const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') + const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') + + fs.mkdirSync(distSkillsDir, {recursive: true}) + fs.mkdirSync(srcSkillsDir, {recursive: true}) + + const orphanFilePath = path.join(distSkillsDir, `${orphanName}.mdx`) // Create orphan file (no source) + fs.writeFileSync(orphanFilePath, `# ${orphanName}`, 'utf8') + + const validFilePath = path.join(distSkillsDir, `${validName}.mdx`) // Create valid file with source + fs.writeFileSync(validFilePath, `# ${validName}`, 'utf8') + + const srcSkillDir = path.join(srcSkillsDir, validName) // Create source for valid file + fs.mkdirSync(srcSkillDir, {recursive: true}) + fs.writeFileSync(path.join(srcSkillDir, 'SKILL.cn.mdx'), `# ${validName}`, 'utf8') + + const plugin = new OrphanFileCleanupEffectInputPlugin() // Execute plugin + const ctx = createEffectContext(tempDir, shadowProjectDir, false) + const effectMethod = (plugin as any).cleanupOrphanFiles.bind(plugin) + await effectMethod(ctx) + + expect(fs.existsSync(orphanFilePath)).toBe(false) // Verify: Orphan file deleted, valid file kept + expect(fs.existsSync(validFilePath)).toBe(true) + + expect(fs.existsSync(distSkillsDir)).toBe(true) // Verify: Directory should NOT be removed (still has valid file) + } + finally { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup + } + } + ), + {numRuns: 100} + ) + }) + }) +}) + +/** + * Helper function to create source file based on directory type and mapping rules. + */ +function createSourceFile( + shadowProjectDir: string, + dirType: 'skills' | 'commands' | 'agents' | 'app', + name: string +): void { + switch (dirType) { + case 'skills': { + const skillDir = path.join(shadowProjectDir, 'src', 'skills', name) // src/skills/{name}/SKILL.cn.mdx + fs.mkdirSync(skillDir, {recursive: true}) + fs.writeFileSync(path.join(skillDir, 'SKILL.cn.mdx'), `# ${name}`, 'utf8') + break + } + case 'commands': { + const commandsDir = path.join(shadowProjectDir, 'src', 'commands') // src/commands/{name}.cn.mdx + fs.mkdirSync(commandsDir, {recursive: true}) + fs.writeFileSync(path.join(commandsDir, `${name}.cn.mdx`), `# ${name}`, 'utf8') + break + } + case 'agents': { + const agentsDir = path.join(shadowProjectDir, 'src', 'agents') // src/agents/{name}.cn.mdx + fs.mkdirSync(agentsDir, {recursive: true}) + fs.writeFileSync(path.join(agentsDir, `${name}.cn.mdx`), `# ${name}`, 'utf8') + break + } + case 'app': { + const appDir = path.join(shadowProjectDir, 'app') // app/{name}.cn.mdx + fs.mkdirSync(appDir, {recursive: true}) + fs.writeFileSync(path.join(appDir, `${name}.cn.mdx`), `# ${name}`, 'utf8') + break + } + } +} diff --git a/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts b/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts new file mode 100644 index 00000000..b8a8f8c9 --- /dev/null +++ b/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts @@ -0,0 +1,214 @@ +import type {CollectedInputContext, InputEffectContext, InputEffectResult, InputPluginContext} from '@truenine/plugin-shared' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' + +/** + * Result of the orphan file cleanup effect. + */ +export interface OrphanCleanupEffectResult extends InputEffectResult { + readonly deletedFiles: string[] + readonly deletedDirs: string[] +} + +export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { + constructor() { + super('OrphanFileCleanupEffectInputPlugin') + this.registerEffect('orphan-file-cleanup', this.cleanupOrphanFiles.bind(this), 20) + } + + private async cleanupOrphanFiles(ctx: InputEffectContext): Promise { + const {fs, path, shadowProjectDir, dryRun, logger} = ctx + + const distDir = path.join(shadowProjectDir, 'dist') + + const deletedFiles: string[] = [] + const deletedDirs: string[] = [] + const errors: {path: string, error: Error}[] = [] + + if (!fs.existsSync(distDir)) { + logger.debug({action: 'orphan-cleanup', message: 'dist/ directory does not exist, skipping', distDir}) + return { + success: true, + description: 'dist/ directory does not exist, nothing to clean', + deletedFiles, + deletedDirs + } + } + + const distSubDirs = ['skills', 'commands', 'agents', 'app'] + + for (const subDir of distSubDirs) { + const distSubDirPath = path.join(distDir, subDir) + if (fs.existsSync(distSubDirPath)) this.cleanupDirectory(ctx, distSubDirPath, subDir, deletedFiles, deletedDirs, errors, dryRun ?? false) + } + + const hasErrors = errors.length > 0 + if (hasErrors) logger.warn({action: 'orphan-cleanup', errors: errors.map(e => ({path: e.path, error: e.error.message}))}) + + return { + success: !hasErrors, + description: dryRun + ? `Would delete ${deletedFiles.length} files and ${deletedDirs.length} directories` + : `Deleted ${deletedFiles.length} files and ${deletedDirs.length} directories`, + deletedFiles, + deletedDirs, + ...hasErrors && {error: new Error(`${errors.length} errors occurred during cleanup`)} + } + } + + private cleanupDirectory( + ctx: InputEffectContext, + distDirPath: string, + dirType: string, + deletedFiles: string[], + deletedDirs: string[], + errors: {path: string, error: Error}[], + dryRun: boolean + ): void { + const {fs, path, shadowProjectDir, logger} = ctx + + let entries: import('node:fs').Dirent[] + try { + entries = fs.readdirSync(distDirPath, {withFileTypes: true}) + } + catch (error) { + errors.push({path: distDirPath, error: error as Error}) + logger.warn({action: 'orphan-cleanup', message: 'Failed to read directory', path: distDirPath, error: (error as Error).message}) + return + } + + for (const entry of entries) { + const entryPath = path.join(distDirPath, entry.name) + + if (entry.isDirectory()) { + this.cleanupDirectory(ctx, entryPath, dirType, deletedFiles, deletedDirs, errors, dryRun) + + this.removeEmptyDirectory(ctx, entryPath, deletedDirs, errors, dryRun) + } else if (entry.isFile()) { + const isOrphan = this.isOrphanFile(ctx, entryPath, dirType, shadowProjectDir) + + if (isOrphan) { + if (dryRun) { + logger.debug({action: 'orphan-cleanup', dryRun: true, wouldDelete: entryPath}) + deletedFiles.push(entryPath) + } else { + try { + fs.unlinkSync(entryPath) + deletedFiles.push(entryPath) + logger.debug({action: 'orphan-cleanup', deleted: entryPath}) + } + catch (error) { + errors.push({path: entryPath, error: error as Error}) + logger.warn({action: 'orphan-cleanup', message: 'Failed to delete file', path: entryPath, error: (error as Error).message}) + } + } + } + } + } + } + + private isOrphanFile( + ctx: InputEffectContext, + distFilePath: string, + dirType: string, + shadowProjectDir: string + ): boolean { + const {fs, path} = ctx + + const fileName = path.basename(distFilePath) + const isMdxFile = fileName.endsWith('.mdx') + + const distTypeDir = path.join(shadowProjectDir, 'dist', dirType) + const relativeFromType = path.relative(distTypeDir, distFilePath) + const relativeDir = path.dirname(relativeFromType) + const baseName = fileName.replace(/\.mdx$/, '') + + if (isMdxFile) { + const possibleSrcPaths = this.getPossibleSourcePaths(path, shadowProjectDir, dirType, baseName, relativeDir) + + return !possibleSrcPaths.some(srcPath => fs.existsSync(srcPath)) + } + const possibleSrcPaths: string[] = [] + + if (dirType === 'app') possibleSrcPaths.push(path.join(shadowProjectDir, 'app', relativeFromType)) + else possibleSrcPaths.push(path.join(shadowProjectDir, 'src', dirType, relativeFromType)) + + return !possibleSrcPaths.some(srcPath => fs.existsSync(srcPath)) + } + + private getPossibleSourcePaths( + nodePath: typeof import('node:path'), + shadowProjectDir: string, + dirType: string, + baseName: string, + relativeDir: string + ): string[] { + switch (dirType) { + case 'skills': + return relativeDir === '.' + ? [ + nodePath.join(shadowProjectDir, 'src', 'skills', baseName, 'SKILL.cn.mdx'), + nodePath.join(shadowProjectDir, 'src', 'skills', `${baseName}.cn.mdx`) + ] + : [ + nodePath.join(shadowProjectDir, 'src', 'skills', relativeDir, `${baseName}.cn.mdx`) + ] + case 'commands': + return relativeDir === '.' + ? [ + nodePath.join(shadowProjectDir, 'src', 'commands', `${baseName}.cn.mdx`) + ] + : [ + nodePath.join(shadowProjectDir, 'src', 'commands', relativeDir, `${baseName}.cn.mdx`) + ] + case 'agents': + return relativeDir === '.' + ? [ + nodePath.join(shadowProjectDir, 'src', 'agents', `${baseName}.cn.mdx`) + ] + : [ + nodePath.join(shadowProjectDir, 'src', 'agents', relativeDir, `${baseName}.cn.mdx`) + ] + case 'app': + return relativeDir === '.' + ? [ + nodePath.join(shadowProjectDir, 'app', `${baseName}.cn.mdx`) + ] + : [ + nodePath.join(shadowProjectDir, 'app', relativeDir, `${baseName}.cn.mdx`) + ] + default: return [] + } + } + + private removeEmptyDirectory( + ctx: InputEffectContext, + dirPath: string, + deletedDirs: string[], + errors: {path: string, error: Error}[], + dryRun: boolean + ): void { + const {fs, logger} = ctx + + try { + const entries = fs.readdirSync(dirPath) + if (entries.length === 0) { + if (dryRun) { + logger.debug({action: 'orphan-cleanup', dryRun: true, wouldDeleteDir: dirPath}) + deletedDirs.push(dirPath) + } else { + fs.rmdirSync(dirPath) + deletedDirs.push(dirPath) + logger.debug({action: 'orphan-cleanup', deletedDir: dirPath}) + } + } + } + catch (error) { + errors.push({path: dirPath, error: error as Error}) + logger.warn({action: 'orphan-cleanup', message: 'Failed to check/remove directory', path: dirPath, error: (error as Error).message}) + } + } + + collect(_ctx: InputPluginContext): Partial { + return {} + } +} diff --git a/cli/src/plugins/plugin-input-orphan-cleanup-effect/index.ts b/cli/src/plugins/plugin-input-orphan-cleanup-effect/index.ts new file mode 100644 index 00000000..52362bdb --- /dev/null +++ b/cli/src/plugins/plugin-input-orphan-cleanup-effect/index.ts @@ -0,0 +1,6 @@ +export { + OrphanFileCleanupEffectInputPlugin +} from './OrphanFileCleanupEffectInputPlugin' +export type { + OrphanCleanupEffectResult +} from './OrphanFileCleanupEffectInputPlugin' diff --git a/cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.test.ts b/cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.test.ts new file mode 100644 index 00000000..281125ab --- /dev/null +++ b/cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.test.ts @@ -0,0 +1,214 @@ +import type {MdxGlobalScope} from '@truenine/md-compiler/globals' +import type {ILogger, InputPluginContext, PluginOptions, Workspace} from '@truenine/plugin-shared' +import * as path from 'node:path' +import {FilePathKind} from '@truenine/plugin-shared' +import {describe, expect, it, vi} from 'vitest' +import {ProjectPromptInputPlugin} from './ProjectPromptInputPlugin' + +const WORKSPACE_DIR = '/workspace' +const SHADOW_PROJECT_NAME = 'shadow' +const SHADOW_PROJECT_DIR = path.join(WORKSPACE_DIR, SHADOW_PROJECT_NAME) +const SHADOW_PROJECTS_DIR = path.join(SHADOW_PROJECT_DIR, 'dist/app') +const PROJECT_NAME = 'test-project' +const SHADOW_PROJECT_PATH = path.join(SHADOW_PROJECTS_DIR, PROJECT_NAME) +const TARGET_PROJECT_PATH = path.join(WORKSPACE_DIR, PROJECT_NAME) +const PROJECT_MEMORY_FILE = 'agt.mdx' +const SKIP_DIR_NODE_MODULES = 'node_modules' +const SKIP_DIR_GIT = '.git' +const MOCK_MDX_CONTENT = '# Test' + +function createMockLogger(): ILogger { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn() + } +} + +function createMockOptions(): Required { + return { + workspaceDir: WORKSPACE_DIR, + shadowSourceProject: { + name: 'shadow', + skill: {src: 'src/skills', dist: 'dist/skills'}, + fastCommand: {src: 'src/commands', dist: 'dist/commands'}, + subAgent: {src: 'src/agents', dist: 'dist/agents'}, + rule: {src: 'src/rules', dist: 'dist/rules'}, + globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, + project: {src: 'app', dist: 'dist/app'} + }, + fastCommandSeriesOptions: {}, + plugins: [], + logLevel: 'info' + } +} + +function createMockWorkspace(): Workspace { + return { + directory: {pathKind: FilePathKind.Root, path: WORKSPACE_DIR, getDirectoryName: () => 'workspace'}, + projects: [{ + name: PROJECT_NAME, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: PROJECT_NAME, + basePath: WORKSPACE_DIR, + getDirectoryName: () => PROJECT_NAME, + getAbsolutePath: () => TARGET_PROJECT_PATH + } + }] + } +} + +function createMockGlobalScope(): MdxGlobalScope { + return { + profile: {name: 'test', username: 'test', gender: 'male', birthday: '2000-01-01'}, + tool: {name: 'test'}, + env: {}, + os: {platform: 'linux', arch: 'x64', homedir: '/home/test'}, + Md: vi.fn() as unknown as MdxGlobalScope['Md'] + } +} + +interface MockDirEntry {name: string, isDirectory: () => boolean, isFile: () => boolean} +const dirEntry = (name: string): MockDirEntry => ({name, isDirectory: () => true, isFile: () => false}) + +function createCtx(workspace: Workspace, mockFs: unknown): InputPluginContext { + return { + logger: createMockLogger(), + fs: mockFs as typeof import('node:fs'), + path, + glob: vi.fn() as unknown as typeof import('fast-glob'), + userConfigOptions: createMockOptions(), + dependencyContext: {workspace}, + globalScope: createMockGlobalScope() + } +} + +describe('projectPromptInputPlugin', () => { + describe('scanDirectoryRecursive - directory skip behavior', () => { + it('should skip node_modules directory', async () => { + const workspace = createMockWorkspace() + const mockFs = { + existsSync: vi.fn().mockImplementation((p: string) => { + if (p === SHADOW_PROJECTS_DIR || p === SHADOW_PROJECT_PATH) return true + if (p.includes(SKIP_DIR_NODE_MODULES)) return false + return p.endsWith(PROJECT_MEMORY_FILE) + }), + statSync: vi.fn().mockReturnValue({isDirectory: () => true, isFile: () => true}), + readdirSync: vi.fn().mockImplementation((dir: string) => { + if (path.normalize(dir) === path.normalize(SHADOW_PROJECT_PATH)) return [dirEntry(SKIP_DIR_NODE_MODULES), dirEntry('src')] + if (path.normalize(dir) === path.normalize(path.join(SHADOW_PROJECT_PATH, 'src'))) return [] + if (dir.includes(SKIP_DIR_NODE_MODULES)) throw new Error(`Should not scan ${SKIP_DIR_NODE_MODULES}`) + return [] + }), + readFileSync: vi.fn().mockReturnValue(MOCK_MDX_CONTENT) + } + const result = await new ProjectPromptInputPlugin().collect(createCtx(workspace, mockFs)) + const project = result.workspace?.projects.find(p => p.name === PROJECT_NAME) + const matched = project?.childMemoryPrompts?.filter(c => c.dir.path.includes(SKIP_DIR_NODE_MODULES)) + expect(matched ?? []).toHaveLength(0) + }) + + it('should skip .git directory', async () => { + const workspace = createMockWorkspace() + const mockFs = { + existsSync: vi.fn().mockImplementation((p: string) => { + if (p === SHADOW_PROJECTS_DIR || p === SHADOW_PROJECT_PATH) return true + if (p.includes(SKIP_DIR_GIT)) return false + return p.endsWith(PROJECT_MEMORY_FILE) + }), + statSync: vi.fn().mockReturnValue({isDirectory: () => true, isFile: () => true}), + readdirSync: vi.fn().mockImplementation((dir: string) => { + if (path.normalize(dir) === path.normalize(SHADOW_PROJECT_PATH)) return [dirEntry(SKIP_DIR_GIT), dirEntry('src')] + if (path.normalize(dir) === path.normalize(path.join(SHADOW_PROJECT_PATH, 'src'))) return [] + if (dir.includes(SKIP_DIR_GIT)) throw new Error(`Should not scan ${SKIP_DIR_GIT}`) + return [] + }), + readFileSync: vi.fn().mockReturnValue(MOCK_MDX_CONTENT) + } + const result = await new ProjectPromptInputPlugin().collect(createCtx(workspace, mockFs)) + const project = result.workspace?.projects.find(p => p.name === PROJECT_NAME) + const matched = project?.childMemoryPrompts?.filter(c => c.dir.path.includes(SKIP_DIR_GIT)) + expect(matched ?? []).toHaveLength(0) + }) + + it('should allow .vscode directory with agt.mdx', async () => { + const workspace = createMockWorkspace() + const vscodeDir = '.vscode' + const vscodePath = path.join(SHADOW_PROJECT_PATH, vscodeDir) + const mockFs = { + existsSync: vi.fn().mockImplementation((p: string) => { + if (p === SHADOW_PROJECTS_DIR || p === SHADOW_PROJECT_PATH) return true + return path.normalize(p) === path.normalize(path.join(vscodePath, PROJECT_MEMORY_FILE)) + }), + statSync: vi.fn().mockReturnValue({isDirectory: () => true, isFile: () => true}), + readdirSync: vi.fn().mockImplementation((dir: string) => { + if (path.normalize(dir) === path.normalize(SHADOW_PROJECT_PATH)) return [dirEntry(vscodeDir)] + return [] + }), + readFileSync: vi.fn().mockReturnValue(MOCK_MDX_CONTENT) + } + const result = await new ProjectPromptInputPlugin().collect(createCtx(workspace, mockFs)) + const project = result.workspace?.projects.find(p => p.name === PROJECT_NAME) + expect(project?.childMemoryPrompts).toHaveLength(1) + expect(project?.childMemoryPrompts?.[0]?.dir.path).toBe(vscodeDir) + }) + + it('should allow .idea directory with agt.mdx', async () => { + const workspace = createMockWorkspace() + const ideaDir = '.idea' + const ideaPath = path.join(SHADOW_PROJECT_PATH, ideaDir) + const mockFs = { + existsSync: vi.fn().mockImplementation((p: string) => { + if (p === SHADOW_PROJECTS_DIR || p === SHADOW_PROJECT_PATH) return true + return path.normalize(p) === path.normalize(path.join(ideaPath, PROJECT_MEMORY_FILE)) + }), + statSync: vi.fn().mockReturnValue({isDirectory: () => true, isFile: () => true}), + readdirSync: vi.fn().mockImplementation((dir: string) => { + if (path.normalize(dir) === path.normalize(SHADOW_PROJECT_PATH)) return [dirEntry(ideaDir)] + return [] + }), + readFileSync: vi.fn().mockReturnValue(MOCK_MDX_CONTENT) + } + const result = await new ProjectPromptInputPlugin().collect(createCtx(workspace, mockFs)) + const project = result.workspace?.projects.find(p => p.name === PROJECT_NAME) + expect(project?.childMemoryPrompts).toHaveLength(1) + expect(project?.childMemoryPrompts?.[0]?.dir.path).toBe(ideaDir) + }) + + it('should scan mixed directories, skipping only node_modules and .git', async () => { + const workspace = createMockWorkspace() + const allowedDirs = ['.vscode', '.idea', 'src', 'app'] + const skippedDirs = [SKIP_DIR_NODE_MODULES, SKIP_DIR_GIT] + const allDirs = [...allowedDirs, ...skippedDirs] + const mockFs = { + existsSync: vi.fn().mockImplementation((p: string) => { + if (p === SHADOW_PROJECTS_DIR || p === SHADOW_PROJECT_PATH) return true + for (const dir of allowedDirs) { + if (path.normalize(p) === path.normalize(path.join(SHADOW_PROJECT_PATH, dir, PROJECT_MEMORY_FILE))) return true + } + return false + }), + statSync: vi.fn().mockReturnValue({isDirectory: () => true, isFile: () => true}), + readdirSync: vi.fn().mockImplementation((dir: string) => { + if (path.normalize(dir) === path.normalize(SHADOW_PROJECT_PATH)) return allDirs.map(d => dirEntry(d)) + for (const d of skippedDirs) { + if (dir.includes(d)) throw new Error(`Should not scan skipped directory: ${d}`) + } + return [] + }), + readFileSync: vi.fn().mockReturnValue(MOCK_MDX_CONTENT) + } + const result = await new ProjectPromptInputPlugin().collect(createCtx(workspace, mockFs)) + const project = result.workspace?.projects.find(p => p.name === PROJECT_NAME) + expect(project?.childMemoryPrompts).toHaveLength(allowedDirs.length) + const collectedPaths = project?.childMemoryPrompts?.map(c => c.dir.path) ?? [] + for (const dir of allowedDirs) expect(collectedPaths).toContain(dir) + for (const dir of skippedDirs) expect(collectedPaths).not.toContain(dir) + }) + }) +}) diff --git a/cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts b/cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts new file mode 100644 index 00000000..5ac29d81 --- /dev/null +++ b/cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts @@ -0,0 +1,235 @@ +import type { + CollectedInputContext, + InputPluginContext, + ProjectChildrenMemoryPrompt, + ProjectRootMemoryPrompt, + YAMLFrontMatter +} from '@truenine/plugin-shared' + +import process from 'node:process' + +import {mdxToMd} from '@truenine/md-compiler' +import {ScopeError} from '@truenine/md-compiler/errors' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' +import { + FilePathKind, + PromptKind +} from '@truenine/plugin-shared' + +/** + * Project memory prompt file name + */ +const PROJECT_MEMORY_FILE = 'agt.mdx' + +/** + * Directories to skip during recursive scanning + */ +const SCAN_SKIP_DIRECTORIES: readonly string[] = ['node_modules', '.git'] as const + +export class ProjectPromptInputPlugin extends AbstractInputPlugin { + constructor() { + super('ProjectPromptInputPlugin', ['ShadowProjectInputPlugin']) + } + + async collect(ctx: InputPluginContext): Promise> { + const {dependencyContext, fs, userConfigOptions: options, path, globalScope} = ctx + const {shadowProjectDir} = this.resolveBasePaths(options) + + const shadowProjectsDir = this.resolveShadowPath(options.shadowSourceProject.project.dist, shadowProjectDir) + + const dependencyWorkspace = dependencyContext.workspace + if (dependencyWorkspace == null) { + this.log.warn('No workspace found in dependency context, skipping project prompt enhancement') + return {} + } + + const projects = dependencyWorkspace.projects ?? [] + + const enhancedProjects = await Promise.all(projects.map(async project => { + const projectName = project.name + if (projectName == null) return project + + const shadowProjectPath = path.join(shadowProjectsDir, projectName) + if (!fs.existsSync(shadowProjectPath) || !fs.statSync(shadowProjectPath).isDirectory()) return project + + const targetProjectPath = project.dirFromWorkspacePath?.getAbsolutePath() + + const rootMemoryPrompt = await this.readRootMemoryPrompt(ctx, shadowProjectPath, globalScope) + const childMemoryPrompts = targetProjectPath != null + ? await this.scanChildMemoryPrompts(ctx, shadowProjectPath, targetProjectPath, globalScope) + : [] + + return { + ...project, + ...rootMemoryPrompt != null && {rootMemoryPrompt}, + ...childMemoryPrompts.length > 0 && {childMemoryPrompts} + } + })) + + return { + workspace: { + directory: dependencyWorkspace.directory, + projects: enhancedProjects + } + } + } + + private async readRootMemoryPrompt( + ctx: InputPluginContext, + projectPath: string, + globalScope: InputPluginContext['globalScope'] + ): Promise { + const {fs, path, logger} = ctx + const filePath = path.join(projectPath, PROJECT_MEMORY_FILE) + + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return + + try { + const rawContent = fs.readFileSync(filePath, 'utf8') + const parsed = parseMarkdown(rawContent) + + let content: string + try { + content = await mdxToMd(rawContent, {globalScope, basePath: projectPath}) + } + catch (e) { + if (e instanceof ScopeError) { + logger.error(`MDX compilation failed in ${filePath}: ${e.message}`) + logger.error(`Please check your configuration file (~/.aindex/.tnmsc.json) and ensure all required variables are defined.`) + process.exit(1) + } + throw e + } + + return { + type: PromptKind.ProjectRootMemory, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + ...parsed.yamlFrontMatter != null && {yamlFrontMatter: parsed.yamlFrontMatter}, + ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, + markdownAst: parsed.markdownAst, + markdownContents: parsed.markdownContents, + dir: { + pathKind: FilePathKind.Root, + path: '', + getDirectoryName: () => '' + } + } + } + catch (e) { + logger.error(`Failed to read root memory prompt at ${filePath}`, {error: e}) + return void 0 + } + } + + private async scanChildMemoryPrompts( + ctx: InputPluginContext, + shadowProjectPath: string, + targetProjectPath: string, + globalScope: InputPluginContext['globalScope'] + ): Promise { + const {logger} = ctx + const prompts: ProjectChildrenMemoryPrompt[] = [] + + try { + await this.scanDirectoryRecursive(ctx, shadowProjectPath, shadowProjectPath, targetProjectPath, prompts, globalScope) + } + catch (e) { + logger.error(`Failed to scan child memory prompts at ${shadowProjectPath}`, {error: e}) + } + + return prompts + } + + private async scanDirectoryRecursive( + ctx: InputPluginContext, + shadowProjectPath: string, + currentPath: string, + targetProjectPath: string, + prompts: ProjectChildrenMemoryPrompt[], + globalScope: InputPluginContext['globalScope'] + ): Promise { + const {fs, path} = ctx + + const entries = fs.readdirSync(currentPath, {withFileTypes: true}) + for (const entry of entries) { + if (!entry.isDirectory()) continue + + if (SCAN_SKIP_DIRECTORIES.includes(entry.name)) continue + + const childDir = path.join(currentPath, entry.name) + const memoryFile = path.join(childDir, PROJECT_MEMORY_FILE) + + if (Boolean(fs.existsSync(memoryFile)) && Boolean(fs.statSync(memoryFile).isFile())) { + const prompt = await this.readChildMemoryPrompt(ctx, shadowProjectPath, childDir, targetProjectPath, globalScope) + if (prompt != null) prompts.push(prompt) + } + + await this.scanDirectoryRecursive(ctx, shadowProjectPath, childDir, targetProjectPath, prompts, globalScope) + } + } + + private async readChildMemoryPrompt( + ctx: InputPluginContext, + shadowProjectPath: string, + shadowChildDir: string, + targetProjectPath: string, + globalScope: InputPluginContext['globalScope'] + ): Promise { + const {fs, path, logger} = ctx + const filePath = path.join(shadowChildDir, PROJECT_MEMORY_FILE) + + try { + const rawContent = fs.readFileSync(filePath, 'utf8') + const parsed = parseMarkdown(rawContent) + + let content: string + try { + content = await mdxToMd(rawContent, {globalScope, basePath: shadowChildDir}) + } + catch (e) { + if (e instanceof ScopeError) { + logger.error(`MDX compilation failed in ${filePath}: ${e.message}`) + logger.error(`Please check your configuration file (~/.aindex/.tnmsc.json) and ensure all required variables are defined.`) + process.exit(1) + } + throw e + } + + const relativePath = path.relative(shadowProjectPath, shadowChildDir) + const targetChildDir = path.join(targetProjectPath, relativePath) + const dirName = path.basename(shadowChildDir) + + return { + type: PromptKind.ProjectChildrenMemory, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + ...parsed.yamlFrontMatter != null && {yamlFrontMatter: parsed.yamlFrontMatter}, + ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, + markdownAst: parsed.markdownAst, + markdownContents: parsed.markdownContents, + dir: { + pathKind: FilePathKind.Relative, + path: relativePath, + basePath: targetProjectPath, + getDirectoryName: () => dirName, + getAbsolutePath: () => targetChildDir + }, + workingChildDirectoryPath: { + pathKind: FilePathKind.Relative, + path: relativePath, + basePath: targetProjectPath, + getDirectoryName: () => dirName, + getAbsolutePath: () => targetChildDir + } + } + } + catch (e) { + logger.error(`Failed to read child memory prompt at ${filePath}`, {error: e}) + return void 0 + } + } +} diff --git a/cli/src/plugins/plugin-input-project-prompt/index.ts b/cli/src/plugins/plugin-input-project-prompt/index.ts new file mode 100644 index 00000000..697fcfee --- /dev/null +++ b/cli/src/plugins/plugin-input-project-prompt/index.ts @@ -0,0 +1,3 @@ +export { + ProjectPromptInputPlugin +} from './ProjectPromptInputPlugin' diff --git a/cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.property.test.ts b/cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.property.test.ts new file mode 100644 index 00000000..630fba39 --- /dev/null +++ b/cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.property.test.ts @@ -0,0 +1,365 @@ +import type {InputPluginContext, PluginOptions, ReadmeFileKind} from '@truenine/plugin-shared' + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {createLogger, README_FILE_KIND_MAP} from '@truenine/plugin-shared' +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' +import {ReadmeMdInputPlugin} from './ReadmeMdInputPlugin' + +/** + * Feature: readme-md-plugin + * Property-based tests for ReadmeMdInputPlugin + */ +describe('readmeMdInputPlugin property tests', () => { + const plugin = new ReadmeMdInputPlugin() + + const allFileKinds = Object.keys(README_FILE_KIND_MAP) as ReadmeFileKind[] + + function createMockContext(workspaceDir: string, _shadowProjectDir: string): InputPluginContext { + const options: PluginOptions = { + workspaceDir, + shadowSourceProject: { + name: '.', + skill: {src: 'src/skills', dist: 'dist/skills'}, + fastCommand: {src: 'src/commands', dist: 'dist/commands'}, + subAgent: {src: 'src/agents', dist: 'dist/agents'}, + rule: {src: 'src/rules', dist: 'dist/rules'}, + globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, + project: {src: 'app', dist: 'ref'} + } + } + + return { + userConfigOptions: options, + logger: createLogger('test', 'error'), + fs, + path + } + } + + function createDirectoryStructure( + baseDir: string, + structure: Record + ): void { + for (const [filePath, content] of Object.entries(structure)) { + const fullPath = path.join(baseDir, filePath) + const dir = path.dirname(fullPath) + + if (!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true}) + + if (content !== null) fs.writeFileSync(fullPath, content, 'utf8') + } + } + + async function withTempDir(fn: (tempDir: string) => Promise): Promise { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'readme-test-')) + try { + return await fn(tempDir) + } + finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + } + + describe('property 1: README Discovery Completeness', () => { + const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) + .filter(s => s.trim().length > 0) + + const fileKindArb = fc.constantFrom(...allFileKinds) + + it('should discover all rdm.mdx files in generated directory structures', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(projectNameArb, {minLength: 1, maxLength: 3}), + fc.array(subdirNameArb, {minLength: 0, maxLength: 2}), + fc.boolean(), + readmeContentArb, + async (projectNames, subdirs, includeRoot, content) => { + await withTempDir(async tempDir => { + const uniqueProjects = [...new Set(projectNames.map(p => p.toLowerCase()))] + const uniqueSubdirs = [...new Set(subdirs.map(s => s.toLowerCase()))] + + const structure: Record = {} + const expectedReadmes: {projectName: string, isRoot: boolean, subdir?: string}[] = [] + + for (const projectName of uniqueProjects) { + structure[`ref/${projectName}/.gitkeep`] = '' + + if (includeRoot) { + structure[`ref/${projectName}/rdm.mdx`] = content + expectedReadmes.push({projectName, isRoot: true}) + } + + for (const subdir of uniqueSubdirs) { + structure[`ref/${projectName}/${subdir}/rdm.mdx`] = content + expectedReadmes.push({projectName, isRoot: false, subdir}) + } + } + + createDirectoryStructure(tempDir, structure) + + const ctx = createMockContext(tempDir, tempDir) + const result = await plugin.collect(ctx) + + const readmePrompts = result.readmePrompts ?? [] + + expect(readmePrompts.length).toBe(expectedReadmes.length) + + for (const expected of expectedReadmes) { + const found = readmePrompts.find( + r => + r.projectName === expected.projectName + && r.isRoot === expected.isRoot + && r.fileKind === 'Readme' + ) + expect(found).toBeDefined() + expect(found?.content).toBe(content) + } + }) + } + ), + {numRuns: 50} + ) + }) + + it('should return empty result when shadow source directory does not exist', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + async projectName => { + await withTempDir(async tempDir => { + const workspaceDir = path.join(tempDir, projectName) + fs.mkdirSync(workspaceDir, {recursive: true}) + + const ctx = createMockContext(workspaceDir, workspaceDir) + const result = await plugin.collect(ctx) + + expect(result.readmePrompts).toEqual([]) + }) + } + ), + {numRuns: 100} + ) + }) + + it('should discover all three file kinds (rdm.mdx, coc.mdx, security.mdx)', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + fc.subarray(allFileKinds, {minLength: 1}), + async (projectName, content, fileKinds) => { + await withTempDir(async tempDir => { + const structure: Record = {} + + for (const kind of fileKinds) { + const srcFile = README_FILE_KIND_MAP[kind].src + structure[`ref/${projectName}/${srcFile}`] = `${content}-${kind}` + } + + createDirectoryStructure(tempDir, structure) + + const ctx = createMockContext(tempDir, tempDir) + const result = await plugin.collect(ctx) + const readmePrompts = result.readmePrompts ?? [] + + expect(readmePrompts.length).toBe(fileKinds.length) + + for (const kind of fileKinds) { + const found = readmePrompts.find(r => r.fileKind === kind) + expect(found).toBeDefined() + expect(found?.content).toBe(`${content}-${kind}`) + expect(found?.projectName).toBe(projectName) + expect(found?.isRoot).toBe(true) + } + }) + } + ), + {numRuns: 100} + ) + }) + + it('should assign correct fileKind for each source file', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + fileKindArb, + readmeContentArb, + async (projectName, fileKind, content) => { + await withTempDir(async tempDir => { + const srcFile = README_FILE_KIND_MAP[fileKind].src + const structure: Record = { + [`ref/${projectName}/${srcFile}`]: content + } + + createDirectoryStructure(tempDir, structure) + + const ctx = createMockContext(tempDir, tempDir) + const result = await plugin.collect(ctx) + const readmePrompts = result.readmePrompts ?? [] + + expect(readmePrompts.length).toBe(1) + expect(readmePrompts[0].fileKind).toBe(fileKind) + expect(readmePrompts[0].content).toBe(content) + }) + } + ), + {numRuns: 100} + ) + }) + }) + + describe('property 2: Data Structure Correctness', () => { + const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) + .filter(s => s.trim().length > 0) + + it('should correctly set isRoot flag based on file location', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + subdirNameArb, + readmeContentArb, + readmeContentArb, + async (projectName, subdir, rootContent, childContent) => { + await withTempDir(async tempDir => { + const structure: Record = { + [`ref/${projectName}/rdm.mdx`]: rootContent, + [`ref/${projectName}/${subdir}/rdm.mdx`]: childContent + } + + createDirectoryStructure(tempDir, structure) + + const ctx = createMockContext(tempDir, tempDir) + const result = await plugin.collect(ctx) + const readmePrompts = result.readmePrompts ?? [] + + const rootReadme = readmePrompts.find(r => r.isRoot) + expect(rootReadme).toBeDefined() + expect(rootReadme?.projectName).toBe(projectName) + expect(rootReadme?.content).toBe(rootContent) + expect(rootReadme?.targetDir.path).toBe(projectName) + + const childReadme = readmePrompts.find(r => !r.isRoot) + expect(childReadme).toBeDefined() + expect(childReadme?.projectName).toBe(projectName) + expect(childReadme?.content).toBe(childContent) + expect(childReadme?.targetDir.path).toBe(path.join(projectName, subdir)) + }) + } + ), + {numRuns: 100} + ) + }) + + it('should preserve content exactly as read from file', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + async (projectName, content) => { + await withTempDir(async tempDir => { + const structure: Record = { + [`ref/${projectName}/rdm.mdx`]: content + } + + createDirectoryStructure(tempDir, structure) + + const ctx = createMockContext(tempDir, tempDir) + const result = await plugin.collect(ctx) + const readmePrompts = result.readmePrompts ?? [] + + expect(readmePrompts.length).toBe(1) + expect(readmePrompts[0].content).toBe(content) + expect(readmePrompts[0].length).toBe(content.length) + }) + } + ), + {numRuns: 100} + ) + }) + + it('should correctly set targetDir with proper path structure', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + fc.array(subdirNameArb, {minLength: 1, maxLength: 3}), + readmeContentArb, + async (projectName, subdirs, content) => { + await withTempDir(async tempDir => { + const uniqueSubdirs = [...new Set(subdirs)] + const structure: Record = {} + + for (const subdir of uniqueSubdirs) structure[`ref/${projectName}/${subdir}/rdm.mdx`] = content + + createDirectoryStructure(tempDir, structure) + + const ctx = createMockContext(tempDir, tempDir) + const result = await plugin.collect(ctx) + const readmePrompts = result.readmePrompts ?? [] + + for (const readme of readmePrompts) { + expect(readme.targetDir.basePath).toBe(tempDir) + expect(readme.targetDir.getAbsolutePath()).toBe( + path.resolve(tempDir, readme.targetDir.path) + ) + } + }) + } + ), + {numRuns: 100} + ) + }) + + it('should discover coc.mdx and security.mdx in child directories', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + subdirNameArb, + readmeContentArb, + async (projectName, subdir, content) => { + await withTempDir(async tempDir => { + const structure: Record = { + [`ref/${projectName}/${subdir}/coc.mdx`]: `coc-${content}`, + [`ref/${projectName}/${subdir}/security.mdx`]: `sec-${content}` + } + + createDirectoryStructure(tempDir, structure) + + const ctx = createMockContext(tempDir, tempDir) + const result = await plugin.collect(ctx) + const readmePrompts = result.readmePrompts ?? [] + + expect(readmePrompts.length).toBe(2) + + const cocPrompt = readmePrompts.find(r => r.fileKind === 'CodeOfConduct') + expect(cocPrompt).toBeDefined() + expect(cocPrompt?.isRoot).toBe(false) + expect(cocPrompt?.content).toBe(`coc-${content}`) + + const secPrompt = readmePrompts.find(r => r.fileKind === 'Security') + expect(secPrompt).toBeDefined() + expect(secPrompt?.isRoot).toBe(false) + expect(secPrompt?.content).toBe(`sec-${content}`) + }) + } + ), + {numRuns: 100} + ) + }) + }) +}) diff --git a/cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.ts b/cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.ts new file mode 100644 index 00000000..28460926 --- /dev/null +++ b/cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.ts @@ -0,0 +1,155 @@ +import type {CollectedInputContext, InputPluginContext, ReadmeFileKind, ReadmePrompt, RelativePath} from '@truenine/plugin-shared' + +import process from 'node:process' + +import {mdxToMd} from '@truenine/md-compiler' +import {ScopeError} from '@truenine/md-compiler/errors' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' +import {FilePathKind, PromptKind, README_FILE_KIND_MAP} from '@truenine/plugin-shared' + +const ALL_FILE_KINDS = Object.entries(README_FILE_KIND_MAP) as [ReadmeFileKind, {src: string, out: string}][] + +/** + * Input plugin for collecting readme-family mdx files from shadow project directories. + * Scans dist/app/ directories for rdm.mdx, coc.mdx, security.mdx files + * and collects them as ReadmePrompt objects. + * + * Supports both root files (in project root) and child files (in subdirectories). + * + * Source → Output mapping: + * - rdm.mdx → README.md + * - coc.mdx → CODE_OF_CONDUCT.md + * - security.mdx → SECURITY.md + */ +export class ReadmeMdInputPlugin extends AbstractInputPlugin { + constructor() { + super('ReadmeMdInputPlugin', ['ShadowProjectInputPlugin']) + } + + async collect(ctx: InputPluginContext): Promise> { + const {userConfigOptions: options, logger, fs, path, globalScope} = ctx + const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(options) + + const shadowProjectsDir = this.resolveShadowPath(options.shadowSourceProject.project.dist, shadowProjectDir) + + const readmePrompts: ReadmePrompt[] = [] + + if (!fs.existsSync(shadowProjectsDir) || !fs.statSync(shadowProjectsDir).isDirectory()) { + logger.debug('shadow projects directory does not exist', {path: shadowProjectsDir}) + return {readmePrompts} + } + + try { + const projectEntries = fs.readdirSync(shadowProjectsDir, {withFileTypes: true}) + + for (const projectEntry of projectEntries) { + if (!projectEntry.isDirectory()) continue + + const projectName = projectEntry.name + const projectDir = path.join(shadowProjectsDir, projectName) + + await this.collectReadmeFiles( + ctx, + projectDir, + projectName, + workspaceDir, + '', + readmePrompts, + globalScope + ) + } + } + catch (e) { + logger.error('failed to scan shadow projects', {path: shadowProjectsDir, error: e}) + } + + return {readmePrompts} + } + + private async collectReadmeFiles( + ctx: InputPluginContext, + currentDir: string, + projectName: string, + workspaceDir: string, + relativePath: string, + readmePrompts: ReadmePrompt[], + globalScope: InputPluginContext['globalScope'] + ): Promise { + const {fs, path, logger} = ctx + const isRoot = relativePath === '' + + for (const [fileKind, {src}] of ALL_FILE_KINDS) { + const filePath = path.join(currentDir, src) + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) continue + + try { + const rawContent = fs.readFileSync(filePath, 'utf8') + + let content: string + if (globalScope != null) { + try { + content = await mdxToMd(rawContent, {globalScope, basePath: currentDir}) + } + catch (e) { + if (e instanceof ScopeError) { + logger.error(`MDX compilation failed in ${filePath}: ${(e as Error).message}`) + logger.error(`Please check your configuration file (~/.aindex/.tnmsc.json) and ensure all required variables are defined.`) + process.exit(1) + } + throw e + } + } else content = rawContent + + const targetPath = isRoot ? projectName : path.join(projectName, relativePath) + + const targetDir: RelativePath = { + pathKind: FilePathKind.Relative, + path: targetPath, + basePath: workspaceDir, + getDirectoryName: () => isRoot ? projectName : path.basename(relativePath), + getAbsolutePath: () => path.resolve(workspaceDir, targetPath) + } + + const dir: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.dirname(filePath), + basePath: workspaceDir, + getDirectoryName: () => path.basename(path.dirname(filePath)), + getAbsolutePath: () => path.dirname(filePath) + } + + readmePrompts.push({ + type: PromptKind.Readme, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + projectName, + targetDir, + isRoot, + fileKind, + markdownContents: [], + dir + }) + } + catch (e) { + logger.warn('failed to read readme-family file', {path: filePath, fileKind, error: e}) + } + } + + try { + const entries = fs.readdirSync(currentDir, {withFileTypes: true}) + + for (const entry of entries) { + if (entry.isDirectory()) { + const subRelativePath = isRoot ? entry.name : path.join(relativePath, entry.name) + const subDir = path.join(currentDir, entry.name) + + await this.collectReadmeFiles(ctx, subDir, projectName, workspaceDir, subRelativePath, readmePrompts, globalScope) + } + } + } + catch (e) { + logger.warn('failed to scan directory', {path: currentDir, error: e}) + } + } +} diff --git a/cli/src/plugins/plugin-input-readme/index.ts b/cli/src/plugins/plugin-input-readme/index.ts new file mode 100644 index 00000000..b3b2628f --- /dev/null +++ b/cli/src/plugins/plugin-input-readme/index.ts @@ -0,0 +1,3 @@ +export { + ReadmeMdInputPlugin +} from './ReadmeMdInputPlugin' diff --git a/cli/src/plugins/plugin-input-rule/RuleInputPlugin.test.ts b/cli/src/plugins/plugin-input-rule/RuleInputPlugin.test.ts new file mode 100644 index 00000000..d0786955 --- /dev/null +++ b/cli/src/plugins/plugin-input-rule/RuleInputPlugin.test.ts @@ -0,0 +1,322 @@ +import type {ILogger, InputPluginContext, RulePrompt} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {validateRuleMetadata} from '@truenine/plugin-shared' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {RuleInputPlugin} from './RuleInputPlugin' + +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) + }) + + it('should pass when seriName is a valid string', () => { + const result = validateRuleMetadata({globs: ['**/*.ts'], description: 'desc', scope: 'project', seriName: 'uniapp3'}) + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + + it('should pass when seriName is absent', () => { + const result = validateRuleMetadata({globs: ['**/*.ts'], description: 'desc', scope: 'project'}) + expect(result.valid).toBe(true) + }) + + it('should fail when seriName is not a string', () => { + const result = validateRuleMetadata({globs: ['**/*.ts'], description: 'desc', scope: 'project', seriName: 42}) + expect(result.valid).toBe(false) + expect(result.errors.some(e => e.includes('seriName'))).toBe(true) + }) + + it('should fail when seriName is an object', () => { + const result = validateRuleMetadata({globs: ['**/*.ts'], description: 'desc', seriName: {}}) + expect(result.valid).toBe(false) + expect(result.errors.some(e => e.includes('seriName'))).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('ruleInputPlugin - seriName propagation', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rule-seri-')) + fs.mkdirSync(path.join(tempDir, 'shadow', 'rules', 'my-series'), {recursive: true}) + }) + + afterEach(() => fs.rmSync(tempDir, {recursive: true, force: true})) + + function createCtx(): InputPluginContext { + return { + logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as unknown as ILogger, + fs, + path, + glob: vi.fn() as never, + userConfigOptions: { + workspaceDir: tempDir, + shadowSourceProject: { + name: 'shadow', + skill: {src: 'src/skills', dist: 'dist/skills'}, + fastCommand: {src: 'src/commands', dist: 'dist/commands'}, + subAgent: {src: 'src/agents', dist: 'dist/agents'}, + rule: {src: 'src/rules', dist: 'rules'}, + globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, + project: {src: 'app', dist: 'dist/app'} + }, + fastCommandSeriesOptions: {}, + plugins: [], + logLevel: 'info' + } as never, + dependencyContext: {}, + globalScope: { + profile: {name: 'test', username: 'test', gender: 'male', birthday: '2000-01-01'}, + tool: {name: 'test'}, + env: {}, + os: {platform: 'linux', arch: 'x64', homedir: '/home/test'}, + Md: vi.fn() + } as never + } + } + + it('should propagate seriName from YAML front matter to RulePrompt', async () => { + fs.writeFileSync( + path.join(tempDir, 'shadow', 'rules', 'my-series', 'my-rule.mdx'), + ['---', 'globs: ["**/*.ts"]', 'description: Test rule', 'scope: project', 'seriName: uniapp3', 'namingCase: kebab-case', '---', '', '# Rule'].join('\n') + ) + const result = await new RuleInputPlugin().collect(createCtx()) + const rule = result.rules?.[0] + expect(rule?.seriName).toBe('uniapp3') + }) + + it('should leave seriName undefined when not in front matter', async () => { + fs.writeFileSync( + path.join(tempDir, 'shadow', 'rules', 'my-series', 'no-seri.mdx'), + ['---', 'globs: ["**/*.ts"]', 'description: No seri', 'scope: project', 'namingCase: kebab-case', '---', '', '# Rule'].join('\n') + ) + const result = await new RuleInputPlugin().collect(createCtx()) + const rule = result.rules?.[0] + expect(rule?.seriName).toBeUndefined() + }) +}) + +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/plugin-input-rule/RuleInputPlugin.ts b/cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts new file mode 100644 index 00000000..28842b48 --- /dev/null +++ b/cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts @@ -0,0 +1,176 @@ +import type { + CollectedInputContext, + InputPluginContext, + MetadataValidationResult, + PluginOptions, + ResolvedBasePaths, + RulePrompt, + RuleScope, + RuleYAMLFrontMatter +} from '@truenine/plugin-shared' +import {mdxToMd} from '@truenine/md-compiler' +import {MetadataValidationError} from '@truenine/md-compiler/errors' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import {BaseDirectoryInputPlugin} from '@truenine/plugin-input-shared' +import { + FilePathKind, + PromptKind, + validateRuleMetadata +} from '@truenine/plugin-shared' + +export class RuleInputPlugin extends BaseDirectoryInputPlugin { + constructor() { + super('RuleInputPlugin', {configKey: 'shadowSourceProject.rule.dist'}) + } + + protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + return this.resolveShadowPath(options.shadowSourceProject.rule.dist, resolvedPaths.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' + const seriName = yamlFrontMatter?.seriName + + 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, + ...seriName != null && {seriName}, + 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/plugin-input-rule/index.ts b/cli/src/plugins/plugin-input-rule/index.ts new file mode 100644 index 00000000..aa91bf07 --- /dev/null +++ b/cli/src/plugins/plugin-input-rule/index.ts @@ -0,0 +1,3 @@ +export { + RuleInputPlugin +} from './RuleInputPlugin' diff --git a/cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.test.ts b/cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.test.ts new file mode 100644 index 00000000..b0380f19 --- /dev/null +++ b/cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.test.ts @@ -0,0 +1,164 @@ +import type {ILogger, InputPluginContext, PluginOptions} from '@truenine/plugin-shared' +import * as path from 'node:path' +import * as fc from 'fast-check' +import {describe, expect, it, vi} from 'vitest' +import {ShadowProjectInputPlugin} from './ShadowProjectInputPlugin' + +const W = '/workspace' +const SHADOW = 'shadow' +const SHADOW_DIR = path.join(W, SHADOW) +const DIST_APP = path.join(SHADOW_DIR, 'dist/app') +const SRC_APP = path.join(SHADOW_DIR, 'app') + +function mockLogger(): ILogger { + return {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as unknown as ILogger +} + +function mockOptions(): Required { + return { + workspaceDir: W, + shadowSourceProject: { + name: SHADOW, + skill: {src: 'src/skills', dist: 'dist/skills'}, + fastCommand: {src: 'src/commands', dist: 'dist/commands'}, + subAgent: {src: 'src/agents', dist: 'dist/agents'}, + rule: {src: 'src/rules', dist: 'dist/rules'}, + globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, + project: {src: 'app', dist: 'dist/app'} + }, + fastCommandSeriesOptions: {}, + plugins: [], + logLevel: 'info' + } as never +} + +function projectJsoncPath(name: string): string { + return path.join(SRC_APP, name, 'project.jsonc') +} + +function makeDirEntry(name: string) { + return {name, isDirectory: () => true, isFile: () => false} +} + +function createCtx(mockFs: unknown, logger = mockLogger()): InputPluginContext { + return { + logger, + fs: mockFs as typeof import('node:fs'), + path, + glob: vi.fn() as never, + userConfigOptions: mockOptions(), + dependencyContext: {}, + globalScope: void 0 as never + } +} + +function buildMockFs(projectName: string, jsoncContent: string | null) { + const jsoncPath = projectJsoncPath(projectName) + return { + existsSync: vi.fn((p: string) => { + if (p === DIST_APP) return true + if (p === jsoncPath) return jsoncContent != null + return false + }), + statSync: vi.fn(() => ({isDirectory: () => true})), + readdirSync: vi.fn((p: string) => p === DIST_APP ? [makeDirEntry(projectName)] : []), + readFileSync: vi.fn((p: string) => { + if (p === jsoncPath && jsoncContent != null) return jsoncContent + throw new Error(`unexpected readFileSync: ${p}`) + }) + } +} + +describe('shadowProjectInputPlugin - project.jsonc loading', () => { + it('attaches projectConfig when project.jsonc exists', () => { + const config = {rules: {includeSeries: ['uniapp3']}} + const mockFs = buildMockFs('my-project', JSON.stringify(config)) + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) + const project = result.workspace?.projects.find(p => p.name === 'my-project') + expect(project?.projectConfig).toEqual(config) + }) + + it('leaves projectConfig undefined when project.jsonc is absent', () => { + const mockFs = buildMockFs('my-project', null) + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) + const project = result.workspace?.projects.find(p => p.name === 'my-project') + expect(project?.projectConfig).toBeUndefined() + }) + + it('parses JSONC with comments correctly', () => { + const jsonc = '{\n // enable uniapp rules\n "rules": {"includeSeries": ["uniapp3"]}\n}' + const mockFs = buildMockFs('proj', jsonc) + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) + expect(result.workspace?.projects[0]?.projectConfig?.rules?.includeSeries).toEqual(['uniapp3']) + }) + + it('leaves projectConfig undefined and warns on malformed JSONC', () => { + const logger = mockLogger() + const mockFs = buildMockFs('proj', '{invalid json{{') + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs, logger)) + expect(result.workspace?.projects[0]?.projectConfig).toBeUndefined() + }) + + it('attaches mcp.names from project.jsonc', () => { + const config = {mcp: {names: ['context7', 'deepwiki']}} + const mockFs = buildMockFs('proj', JSON.stringify(config)) + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) + expect(result.workspace?.projects[0]?.projectConfig?.mcp?.names).toEqual(['context7', 'deepwiki']) + }) + + it('attaches rules.subSeries from project.jsonc', () => { + const config = {rules: {subSeries: {backend: ['api-rules'], frontend: ['vue-rules']}}} + const mockFs = buildMockFs('proj', JSON.stringify(config)) + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) + expect(result.workspace?.projects[0]?.projectConfig?.rules?.subSeries).toEqual(config.rules.subSeries) + }) + + it('does not affect other project fields when project.jsonc is absent', () => { + const mockFs = buildMockFs('proj', null) + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) + const p = result.workspace?.projects[0] + expect(p?.name).toBe('proj') + expect(p?.dirFromWorkspacePath).toBeDefined() + expect(p?.projectConfig).toBeUndefined() + }) + + it('handles empty project.jsonc object', () => { + const mockFs = buildMockFs('proj', '{}') + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) + expect(result.workspace?.projects[0]?.projectConfig).toEqual({}) + }) +}) + +describe('shadowProjectInputPlugin - project.jsonc property tests', () => { + const projectNameGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,19}$/) + const stringArrayGen = fc.array(fc.string({minLength: 1, maxLength: 20}), {maxLength: 5}) + + it('projectConfig is always attached when project.jsonc exists with valid JSON', () => { + fc.assert(fc.property(projectNameGen, stringArrayGen, (name, include) => { + const config = {rules: {include}} + const mockFs = buildMockFs(name, JSON.stringify(config)) + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) + const project = result.workspace?.projects.find(p => p.name === name) + expect(project?.projectConfig?.rules?.include).toEqual(include) + })) + }) + + it('projectConfig is always undefined when project.jsonc is absent', () => { + fc.assert(fc.property(projectNameGen, name => { + const mockFs = buildMockFs(name, null) + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) + const project = result.workspace?.projects.find(p => p.name === name) + expect(project?.projectConfig).toBeUndefined() + })) + }) + + it('project name is always preserved regardless of projectConfig presence', () => { + fc.assert(fc.property(projectNameGen, fc.boolean(), (name, hasConfig) => { + const mockFs = buildMockFs(name, hasConfig ? '{"mcp": {"names": []}}' : null) + const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) + const project = result.workspace?.projects.find(p => p.name === name) + expect(project?.name).toBe(name) + })) + }) +}) diff --git a/cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.ts b/cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.ts new file mode 100644 index 00000000..ffcc7e2f --- /dev/null +++ b/cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.ts @@ -0,0 +1,118 @@ +import type {CollectedInputContext, InputPluginContext, Project, Workspace} from '@truenine/plugin-shared' +import type {ProjectConfig} from '@truenine/plugin-shared/types' + +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' +import { + FilePathKind +} from '@truenine/plugin-shared' +import {parse as parseJsonc} from 'jsonc-parser' + +export class ShadowProjectInputPlugin extends AbstractInputPlugin { + constructor() { + super('ShadowProjectInputPlugin') + } + + private loadProjectConfig( + projectName: string, + shadowProjectDir: string, + srcPath: string, + fs: InputPluginContext['fs'], + path: InputPluginContext['path'], + logger: InputPluginContext['logger'] + ): ProjectConfig | undefined { + const configPath = path.join(shadowProjectDir, srcPath, projectName, 'project.jsonc') + if (!fs.existsSync(configPath)) return void 0 + try { + const raw = fs.readFileSync(configPath, 'utf8') + const errors: import('jsonc-parser').ParseError[] = [] + const result = parseJsonc(raw, errors) as ProjectConfig + if (errors.length > 0) { + logger.warn(`failed to parse project.jsonc for ${projectName}`, {path: configPath, errors}) + return void 0 + } + return result + } catch (e) { + logger.warn(`failed to parse project.jsonc for ${projectName}`, {path: configPath, error: e}) + return void 0 + } + } + + collect(ctx: InputPluginContext): Partial { + const {userConfigOptions: options, logger, fs, path} = ctx + const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(options) + + const shadowProjectsDir = this.resolveShadowPath(options.shadowSourceProject.project.dist, shadowProjectDir) + + const shadowSourceProjectName = path.basename(shadowProjectDir) + + const shadowProjects: Project[] = [] + + if (fs.existsSync(shadowProjectsDir) && fs.statSync(shadowProjectsDir).isDirectory()) { + try { + const entries = fs.readdirSync(shadowProjectsDir, {withFileTypes: true}) + for (const entry of entries) { + if (entry.isDirectory()) { + const isTheShadowSourceProject = entry.name === shadowSourceProjectName + const projectConfig = this.loadProjectConfig(entry.name, shadowProjectDir, options.shadowSourceProject.project.src, fs, path, logger) + + shadowProjects.push({ + name: entry.name, + ...isTheShadowSourceProject && {isPromptSourceProject: true}, + ...projectConfig != null && {projectConfig}, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: entry.name, + basePath: workspaceDir, + getDirectoryName: () => entry.name, + getAbsolutePath: () => path.resolve(workspaceDir, entry.name) + } + }) + } + } + } + catch (e) { + logger.error('failed to scan shadow projects', {path: shadowProjectsDir, error: e}) + } + } + + if (shadowProjects.length === 0 && fs.existsSync(workspaceDir) && fs.statSync(workspaceDir).isDirectory()) { + logger.debug('no projects in dist/app/, falling back to workspace scan', {workspaceDir}) + try { + const entries = fs.readdirSync(workspaceDir, {withFileTypes: true}) + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.')) { + const isTheShadowSourceProject = entry.name === shadowSourceProjectName + const projectConfig = this.loadProjectConfig(entry.name, shadowProjectDir, options.shadowSourceProject.project.src, fs, path, logger) + + shadowProjects.push({ + name: entry.name, + ...isTheShadowSourceProject && {isPromptSourceProject: true}, + ...projectConfig != null && {projectConfig}, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: entry.name, + basePath: workspaceDir, + getDirectoryName: () => entry.name, + getAbsolutePath: () => path.resolve(workspaceDir, entry.name) + } + }) + } + } + } + catch (e) { + logger.error('failed to scan workspace directory', {path: workspaceDir, error: e}) + } + } + + const workspace: Workspace = { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceDir, + getDirectoryName: () => path.basename(workspaceDir) + }, + projects: shadowProjects + } + + return {workspace} + } +} diff --git a/cli/src/plugins/plugin-input-shadow-project/index.ts b/cli/src/plugins/plugin-input-shadow-project/index.ts new file mode 100644 index 00000000..04ecc9ad --- /dev/null +++ b/cli/src/plugins/plugin-input-shadow-project/index.ts @@ -0,0 +1,3 @@ +export { + ShadowProjectInputPlugin +} from './ShadowProjectInputPlugin' diff --git a/cli/src/plugins/plugin-input-shared-ignore/AIAgentIgnoreInputPlugin.ts b/cli/src/plugins/plugin-input-shared-ignore/AIAgentIgnoreInputPlugin.ts new file mode 100644 index 00000000..2cfb14b4 --- /dev/null +++ b/cli/src/plugins/plugin-input-shared-ignore/AIAgentIgnoreInputPlugin.ts @@ -0,0 +1,47 @@ +import type {AIAgentIgnoreConfigFile, CollectedInputContext, InputPluginContext} from '@truenine/plugin-shared' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' +import {SHADOW_SOURCE_FILE_NAMES} from '@truenine/plugin-shared' + +const IGNORE_FILE_NAMES: readonly string[] = [ + SHADOW_SOURCE_FILE_NAMES.QODER_IGNORE, + SHADOW_SOURCE_FILE_NAMES.CURSOR_IGNORE, + SHADOW_SOURCE_FILE_NAMES.WARP_INDEX_IGNORE, + SHADOW_SOURCE_FILE_NAMES.AI_IGNORE, + SHADOW_SOURCE_FILE_NAMES.CODEIUM_IGNORE, + '.kiroignore', + '.traeignore' +] as const + +/** + * Input plugin that reads AI agent ignore files from shadow source project root. + * Reads files like .kiroignore, .aiignore, .cursorignore, etc. + * and populates aiAgentIgnoreConfigFiles in CollectedInputContext. + */ +export class AIAgentIgnoreInputPlugin extends AbstractInputPlugin { + constructor() { + super('AIAgentIgnoreInputPlugin') + } + + collect(ctx: InputPluginContext): Partial { + const {shadowProjectDir} = this.resolveBasePaths(ctx.userConfigOptions) + const results: AIAgentIgnoreConfigFile[] = [] + + for (const fileName of IGNORE_FILE_NAMES) { + const filePath = ctx.path.join(shadowProjectDir, fileName) + if (!ctx.fs.existsSync(filePath)) { + this.log.debug({action: 'collect', message: 'Ignore file not found', path: filePath}) + continue + } + const content = ctx.fs.readFileSync(filePath, 'utf8') + if (content.length === 0) { + this.log.debug({action: 'collect', message: 'Ignore file is empty', path: filePath}) + continue + } + results.push({fileName, content}) + this.log.debug({action: 'collect', message: 'Loaded ignore file', path: filePath, fileName}) + } + + if (results.length === 0) return {} + return {aiAgentIgnoreConfigFiles: results} + } +} diff --git a/cli/src/plugins/plugin-input-shared-ignore/index.ts b/cli/src/plugins/plugin-input-shared-ignore/index.ts new file mode 100644 index 00000000..64bf7709 --- /dev/null +++ b/cli/src/plugins/plugin-input-shared-ignore/index.ts @@ -0,0 +1,3 @@ +export { + AIAgentIgnoreInputPlugin +} from './AIAgentIgnoreInputPlugin' diff --git a/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts b/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts new file mode 100644 index 00000000..b5ba177c --- /dev/null +++ b/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts @@ -0,0 +1,357 @@ +import type {CollectedInputContext, InputEffectContext, InputEffectResult, InputPluginContext, PluginOptions} from '@truenine/plugin-shared' + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {createLogger} from '@truenine/plugin-shared' +import glob from 'fast-glob' +import {beforeEach, describe, expect, it} from 'vitest' +import {AbstractInputPlugin} from './AbstractInputPlugin' + +function createTestOptions(overrides: Partial = {}): Required { // Default test options for Required + return { + workspaceDir: '/test', + shadowSourceProject: { + name: 'tnmsc-shadow', + skill: {src: 'src/skills', dist: 'dist/skills'}, + fastCommand: {src: 'src/commands', dist: 'dist/commands'}, + subAgent: {src: 'src/agents', dist: 'dist/agents'}, + rule: {src: 'src/rules', dist: 'dist/rules'}, + globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, + project: {src: 'app', dist: 'dist/app'} + }, + fastCommandSeriesOptions: {}, + plugins: [], + logLevel: 'info', + ...overrides + } +} + +class TestInputPlugin extends AbstractInputPlugin { // Concrete implementation for testing + public effectResults: InputEffectResult[] = [] + + constructor(name: string = 'TestInputPlugin', dependsOn?: readonly string[]) { + super(name, dependsOn) + } + + async collect(): Promise> { + return {} + } + + public exposeRegisterEffect( // Expose protected methods for testing + name: string, + handler: (ctx: InputEffectContext) => Promise, + priority?: number + ): void { + this.registerEffect(name, handler, priority) + } + + public exposeResolveBasePaths(options: Required): {workspaceDir: string, shadowProjectDir: string} { + return this.resolveBasePaths(options) + } + + public exposeResolvePath(rawPath: string, workspaceDir: string): string { + return this.resolvePath(rawPath, workspaceDir) + } + + public exposeResolveShadowPath(relativePath: string, shadowProjectDir: string): string { + return this.resolveShadowPath(relativePath, shadowProjectDir) + } + + public exposeRegisterScope(namespace: string, values: Record): void { // Expose scope registration methods for testing + this.registerScope(namespace, values) + } + + public exposeClearRegisteredScopes(): void { + this.clearRegisteredScopes() + } +} + +describe('abstractInputPlugin', () => { + let plugin: TestInputPlugin, + mockLogger: ReturnType + + beforeEach(() => { + plugin = new TestInputPlugin() + mockLogger = createLogger('test') + }) + + describe('effect registration', () => { + it('should register effects', () => { + expect(plugin.hasEffects()).toBe(false) + expect(plugin.getEffectCount()).toBe(0) + + plugin.exposeRegisterEffect('test-effect', async () => ({ + success: true, + description: 'Test effect executed' + })) + + expect(plugin.hasEffects()).toBe(true) + expect(plugin.getEffectCount()).toBe(1) + }) + + it('should sort effects by priority', () => { + const executionOrder: string[] = [] + + plugin.exposeRegisterEffect('low-priority', async () => { + executionOrder.push('low') + return {success: true} + }, 10) + + plugin.exposeRegisterEffect('high-priority', async () => { + executionOrder.push('high') + return {success: true} + }, -10) + + plugin.exposeRegisterEffect('default-priority', async () => { + executionOrder.push('default') + return {success: true} + }) + + expect(plugin.getEffectCount()).toBe(3) + }) + }) + + describe('executeEffects', () => { + it('should execute effects in priority order', async () => { + const executionOrder: string[] = [] + + plugin.exposeRegisterEffect('third', async () => { + executionOrder.push('third') + return {success: true} + }, 10) + + plugin.exposeRegisterEffect('first', async () => { + executionOrder.push('first') + return {success: true} + }, -10) + + plugin.exposeRegisterEffect('second', async () => { + executionOrder.push('second') + return {success: true} + }, 0) + + const ctx: InputPluginContext = { + logger: mockLogger, + fs, + path, + glob, + userConfigOptions: createTestOptions({workspaceDir: '/test'}), + dependencyContext: {} + } + + const results = await plugin.executeEffects(ctx) + + expect(results).toHaveLength(3) + expect(results.every(r => r.success)).toBe(true) + expect(executionOrder).toEqual(['first', 'second', 'third']) + }) + + it('should return empty array when no effects registered', async () => { + const ctx: InputPluginContext = { + logger: mockLogger, + fs, + path, + glob, + userConfigOptions: createTestOptions(), + dependencyContext: {} + } + + const results = await plugin.executeEffects(ctx) + expect(results).toHaveLength(0) + }) + + it('should handle dry-run mode', async () => { + let effectExecuted = false + + plugin.exposeRegisterEffect('test-effect', async () => { + effectExecuted = true + return {success: true} + }) + + const ctx: InputPluginContext = { + logger: mockLogger, + fs, + path, + glob, + userConfigOptions: createTestOptions(), + dependencyContext: {} + } + + const results = await plugin.executeEffects(ctx, true) + + expect(results).toHaveLength(1) + expect(results[0]?.success).toBe(true) + expect(results[0]?.description).toContain('Would execute') + expect(effectExecuted).toBe(false) + }) + + it('should catch and log errors from effects', async () => { + plugin.exposeRegisterEffect('failing-effect', async () => { + throw new Error('Effect failed') + }) + + const ctx: InputPluginContext = { + logger: mockLogger, + fs, + path, + glob, + userConfigOptions: createTestOptions(), + dependencyContext: {} + } + + const results = await plugin.executeEffects(ctx) + + expect(results).toHaveLength(1) + expect(results[0]?.success).toBe(false) + expect(results[0]?.error?.message).toBe('Effect failed') + }) + + it('should continue executing effects after one fails', async () => { + const executionOrder: string[] = [] + + plugin.exposeRegisterEffect('first', async () => { + executionOrder.push('first') + throw new Error('First failed') + }, -10) + + plugin.exposeRegisterEffect('second', async () => { + executionOrder.push('second') + return {success: true} + }, 10) + + const ctx: InputPluginContext = { + logger: mockLogger, + fs, + path, + glob, + userConfigOptions: createTestOptions(), + dependencyContext: {} + } + + const results = await plugin.executeEffects(ctx) + + expect(results).toHaveLength(2) + expect(results[0]?.success).toBe(false) + expect(results[1]?.success).toBe(true) + expect(executionOrder).toEqual(['first', 'second']) + }) + }) + + describe('resolveBasePaths', () => { + it('should resolve workspace and shadow project paths', () => { + const options = createTestOptions({workspaceDir: '/custom/workspace'}) + + const {workspaceDir, shadowProjectDir} = plugin.exposeResolveBasePaths(options) + + expect(workspaceDir).toBe(path.normalize('/custom/workspace')) + expect(shadowProjectDir).toBe(path.normalize('/custom/workspace/tnmsc-shadow')) + }) + + it('should construct shadow project dir from name', () => { + const options = createTestOptions({ + workspaceDir: '~/project', + shadowSourceProject: { + name: 'my-shadow', + skill: {src: 'src/skills', dist: 'dist/skills'}, + fastCommand: {src: 'src/commands', dist: 'dist/commands'}, + subAgent: {src: 'src/agents', dist: 'dist/agents'}, + rule: {src: 'src/rules', dist: 'dist/rules'}, + globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, + workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, + project: {src: 'app', dist: 'dist/app'} + } + }) + + const {workspaceDir, shadowProjectDir} = plugin.exposeResolveBasePaths(options) + + expect(workspaceDir).toContain('project') + expect(shadowProjectDir).toContain('my-shadow') + }) + }) + + describe('resolvePath', () => { + it('should replace ~ with home directory', () => { + const resolved = plugin.exposeResolvePath('~/test', '') + expect(resolved).toBe(path.normalize(`${os.homedir()}/test`)) + }) + + it('should replace $WORKSPACE placeholder', () => { + const resolved = plugin.exposeResolvePath('$WORKSPACE/subdir', '/workspace') + expect(resolved).toBe(path.normalize('/workspace/subdir')) + }) + }) + + describe('resolveShadowPath', () => { + it('should join shadow project dir with relative path', () => { + const resolved = plugin.exposeResolveShadowPath('dist/skills', '/shadow') + expect(resolved).toBe(path.join('/shadow', 'dist/skills')) + }) + }) + + describe('scope registration', () => { + it('should register scope variables', () => { + expect(plugin.getRegisteredScopes()).toHaveLength(0) + + plugin.exposeRegisterScope('myPlugin', {version: '1.0.0'}) + + const scopes = plugin.getRegisteredScopes() + expect(scopes).toHaveLength(1) + expect(scopes[0]?.namespace).toBe('myPlugin') + expect(scopes[0]?.values).toEqual({version: '1.0.0'}) + }) + + it('should register multiple scopes', () => { + plugin.exposeRegisterScope('plugin1', {key1: 'value1'}) + plugin.exposeRegisterScope('plugin2', {key2: 'value2'}) + + const scopes = plugin.getRegisteredScopes() + expect(scopes).toHaveLength(2) + expect(scopes[0]?.namespace).toBe('plugin1') + expect(scopes[1]?.namespace).toBe('plugin2') + }) + + it('should allow registering same namespace multiple times', () => { + plugin.exposeRegisterScope('myPlugin', {key1: 'value1'}) + plugin.exposeRegisterScope('myPlugin', {key2: 'value2'}) + + const scopes = plugin.getRegisteredScopes() + expect(scopes).toHaveLength(2) + expect(scopes[0]?.values).toEqual({key1: 'value1'}) + expect(scopes[1]?.values).toEqual({key2: 'value2'}) + }) + + it('should support nested objects in scope values', () => { + plugin.exposeRegisterScope('myPlugin', { + config: { + debug: true, + nested: {level: 2} + } + }) + + const scopes = plugin.getRegisteredScopes() + expect(scopes[0]?.values).toEqual({ + config: { + debug: true, + nested: {level: 2} + } + }) + }) + + it('should clear registered scopes', () => { + plugin.exposeRegisterScope('myPlugin', {key: 'value'}) + expect(plugin.getRegisteredScopes()).toHaveLength(1) + + plugin.exposeClearRegisteredScopes() + expect(plugin.getRegisteredScopes()).toHaveLength(0) + }) + + it('should return readonly array from getRegisteredScopes', () => { + plugin.exposeRegisterScope('myPlugin', {key: 'value'}) + + const scopes = plugin.getRegisteredScopes() + expect(Array.isArray(scopes)).toBe(true) // TypeScript should prevent modification, but we verify the array is a copy + }) + }) +}) diff --git a/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.ts b/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.ts new file mode 100644 index 00000000..5466d6c0 --- /dev/null +++ b/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.ts @@ -0,0 +1,147 @@ +import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' +import type { + CollectedInputContext, + InputEffectContext, + InputEffectHandler, + InputEffectRegistration, + InputEffectResult, + InputPlugin, + InputPluginContext, + PluginOptions, + PluginScopeRegistration, + ResolvedBasePaths, + YAMLFrontMatter +} from '@truenine/plugin-shared' + +import {spawn} from 'node:child_process' +import * as os from 'node:os' +import * as path from 'node:path' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import { + AbstractPlugin, + PathPlaceholders, + PluginKind +} from '@truenine/plugin-shared' + +export abstract class AbstractInputPlugin extends AbstractPlugin implements InputPlugin { + private readonly inputEffects: InputEffectRegistration[] = [] + + private readonly registeredScopes: PluginScopeRegistration[] = [] + + protected constructor(name: string, dependsOn?: readonly string[]) { + super(name, PluginKind.Input, dependsOn) + } + + protected registerEffect(name: string, handler: InputEffectHandler, priority: number = 0): void { + this.inputEffects.push({name, handler, priority}) + this.inputEffects.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) // Sort by priority (lower = earlier) + } + + async executeEffects(ctx: InputPluginContext, dryRun: boolean = false): Promise { + const results: InputEffectResult[] = [] + + if (this.inputEffects.length === 0) return results + + const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(ctx.userConfigOptions) + + const effectCtx: InputEffectContext = { + logger: this.log, + fs: ctx.fs, + path: ctx.path, + glob: ctx.glob, + spawn, + userConfigOptions: ctx.userConfigOptions, + workspaceDir, + shadowProjectDir, + dryRun + } + + for (const effect of this.inputEffects) { + if (dryRun) { + this.log.trace({action: 'dryRun', type: 'inputEffect', name: effect.name}) + results.push({success: true, description: `Would execute input effect: ${effect.name}`}) + continue + } + + try { + const result = await effect.handler(effectCtx) + if (result.success) { + this.log.trace({action: 'inputEffect', name: effect.name, status: 'success', description: result.description}) + if (result.modifiedFiles != null && result.modifiedFiles.length > 0) { + this.log.debug({action: 'inputEffect', name: effect.name, modifiedFiles: result.modifiedFiles}) + } + if (result.deletedFiles != null && result.deletedFiles.length > 0) { + this.log.debug({action: 'inputEffect', name: effect.name, deletedFiles: result.deletedFiles}) + } + } else { + const errorMsg = result.error instanceof Error ? result.error.message : String(result.error) + this.log.error({action: 'inputEffect', name: effect.name, status: 'failed', error: errorMsg}) + } + results.push(result) + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'inputEffect', name: effect.name, status: 'failed', error: errorMsg}) + results.push({success: false, error: error as Error, description: `Input effect failed: ${effect.name}`}) + } + } + + return results + } + + hasEffects(): boolean { + return this.inputEffects.length > 0 + } + + getEffectCount(): number { + return this.inputEffects.length + } + + protected registerScope(namespace: string, values: Record): void { + this.registeredScopes.push({namespace, values}) + this.log.debug({action: 'registerScope', namespace, keys: Object.keys(values)}) + } + + getRegisteredScopes(): readonly PluginScopeRegistration[] { + return this.registeredScopes + } + + protected clearRegisteredScopes(): void { + this.registeredScopes.length = 0 + this.log.debug({action: 'clearRegisteredScopes'}) + } + + abstract collect(ctx: InputPluginContext): Partial | Promise> + + protected resolveBasePaths(options: Required): ResolvedBasePaths { + const workspaceDirRaw = options.workspaceDir + const workspaceDir = this.resolvePath(workspaceDirRaw, '') + + const shadowProjectName = options.shadowSourceProject.name + const shadowProjectDir = path.join(workspaceDir, shadowProjectName) + + return {workspaceDir, shadowProjectDir} + } + + protected resolvePath(rawPath: string, workspaceDir: string): string { + let resolved = rawPath + + if (resolved.startsWith(PathPlaceholders.USER_HOME)) resolved = resolved.replace(PathPlaceholders.USER_HOME, os.homedir()) + + if (resolved.includes(PathPlaceholders.WORKSPACE)) resolved = resolved.replace(PathPlaceholders.WORKSPACE, workspaceDir) + + return path.normalize(resolved) + } + + protected resolveShadowPath(relativePath: string, shadowProjectDir: string): string { + return path.join(shadowProjectDir, relativePath) + } + + protected readAndParseMarkdown( + filePath: string, + fs: typeof import('node:fs') + ): ParsedMarkdown { + const rawContent = fs.readFileSync(filePath, 'utf8') + return parseMarkdown(rawContent) + } +} diff --git a/cli/src/plugins/plugin-input-shared/BaseDirectoryInputPlugin.ts b/cli/src/plugins/plugin-input-shared/BaseDirectoryInputPlugin.ts new file mode 100644 index 00000000..2c468392 --- /dev/null +++ b/cli/src/plugins/plugin-input-shared/BaseDirectoryInputPlugin.ts @@ -0,0 +1,144 @@ +import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' +import type { + CollectedInputContext, + InputPluginContext, + PluginOptions, + ResolvedBasePaths, + YAMLFrontMatter +} from '@truenine/plugin-shared' +import {mdxToMd} from '@truenine/md-compiler' +import {MetadataValidationError} from '@truenine/md-compiler/errors' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import {AbstractInputPlugin} from './AbstractInputPlugin' + +/** + * Configuration options for BaseDirectoryInputPlugin + */ +export interface DirectoryInputPluginOptions { + readonly configKey: keyof ResolvedBasePaths | string + + readonly extension?: string +} + +/** + * Abstract base class for input plugins that scan a directory for MDX files. + * Provides common logic for: + * - Directoy scanning + * - File reading + * - MDX compilation + * - Metadata validation + * - Error handling + */ +export abstract class BaseDirectoryInputPlugin< + TPrompt extends { + type: string + content: string + yamlFrontMatter?: TYAML + rawFrontMatter?: string + dir: {path: string, basePath: string} + }, + TYAML extends YAMLFrontMatter +> extends AbstractInputPlugin { + protected readonly configKey: string + protected readonly extension: string + + constructor(name: string, options: DirectoryInputPluginOptions) { + super(name) + this.configKey = options.configKey + this.extension = options.extension ?? '.mdx' + } + + protected abstract validateMetadata(metadata: Record, filePath: string): { + valid: boolean + errors: readonly string[] + warnings: readonly string[] + } + + protected abstract getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string + + protected abstract createPrompt( + entryName: string, + filePath: string, + content: string, + yamlFrontMatter: TYAML | undefined, + rawFrontMatter: string | undefined, + parsed: ParsedMarkdown, + baseDir: string, + rawContent: string + ): TPrompt + + protected abstract createResult(items: TPrompt[]): Partial + + async collect(ctx: InputPluginContext): Promise> { + const {userConfigOptions: options, logger, path, fs, globalScope} = ctx + const resolvedPaths = this.resolveBasePaths(options) + + const targetDir = this.getTargetDir(options, resolvedPaths) + const items: TPrompt[] = [] + + 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.isFile() && entry.name.endsWith(this.extension)) { + const filePath = path.join(targetDir, entry.name) + const rawContent = fs.readFileSync(filePath, 'utf8') + + try { + const parsed = parseMarkdown(rawContent) // Parse YAML front matter first for backward compatibility + + const compileResult = await mdxToMd(rawContent, { // Compile MDX with globalScope and extract metadata from exports + globalScope, + extractMetadata: true, + basePath: targetDir + }) + + const mergedFrontMatter: TYAML | undefined = parsed.yamlFrontMatter != null || Object.keys(compileResult.metadata.fields).length > 0 // Merge YAML front matter with export metadata (export takes priority) + ? { + ...parsed.yamlFrontMatter, + ...compileResult.metadata.fields + } as TYAML + : 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 + + logger.debug(`${this.name} metadata extracted`, { + file: entry.name, + source: compileResult.metadata.source, + hasYaml: parsed.yamlFrontMatter != null, + hasExport: Object.keys(compileResult.metadata.fields).length > 0 + }) + + const prompt = this.createPrompt( + entry.name, + filePath, + content, + mergedFrontMatter, + parsed.rawFrontMatter, + parsed, + targetDir, + rawContent + ) + + items.push(prompt) + } catch (e) { + logger.error(`failed to parse ${this.name} item`, {file: filePath, error: e}) + } + } + } + } catch (e) { + logger.error(`Failed to scan directory at ${targetDir}`, {error: e}) + } + + return this.createResult(items) + } +} diff --git a/cli/src/plugins/plugin-input-shared/BaseFileInputPlugin.ts b/cli/src/plugins/plugin-input-shared/BaseFileInputPlugin.ts new file mode 100644 index 00000000..c57b3fc8 --- /dev/null +++ b/cli/src/plugins/plugin-input-shared/BaseFileInputPlugin.ts @@ -0,0 +1,57 @@ +import type { + CollectedInputContext, + InputPluginContext +} from '@truenine/plugin-shared' +import {AbstractInputPlugin} from './AbstractInputPlugin' + +/** + * Options for configuring BaseFileInputPlugin + */ +export interface FileInputPluginOptions { + readonly fallbackContent?: string +} + +export abstract class BaseFileInputPlugin extends AbstractInputPlugin { + protected readonly options: FileInputPluginOptions + + protected constructor(name: string, options?: FileInputPluginOptions) { + super(name) + this.options = options ?? {} + } + + protected abstract getFilePath(shadowProjectDir: string): string + + protected abstract getResultKey(): keyof CollectedInputContext + + protected transformContent(content: string): TResult { + return content as unknown as TResult + } + + collect(ctx: InputPluginContext): Partial { + const {shadowProjectDir} = this.resolveBasePaths(ctx.userConfigOptions) + const filePath = this.getFilePath(shadowProjectDir) + + if (!ctx.fs.existsSync(filePath)) { + if (this.options.fallbackContent != null) { + this.log.debug({action: 'collect', message: 'Using fallback content', path: filePath}) + return {[this.getResultKey()]: this.transformContent(this.options.fallbackContent)} as Partial + } + this.log.debug({action: 'collect', message: 'File not found', path: filePath}) + return {} + } + + const content = ctx.fs.readFileSync(filePath, 'utf8') + + if (content.length === 0) { + if (this.options.fallbackContent != null) { + this.log.debug({action: 'collect', message: 'File empty, using fallback', path: filePath}) + return {[this.getResultKey()]: this.transformContent(this.options.fallbackContent)} as Partial + } + this.log.debug({action: 'collect', message: 'File is empty', path: filePath}) + return {} + } + + this.log.debug({action: 'collect', message: 'Loaded file content', path: filePath, length: content.length}) + return {[this.getResultKey()]: this.transformContent(content)} as Partial + } +} diff --git a/cli/src/plugins/plugin-input-shared/index.ts b/cli/src/plugins/plugin-input-shared/index.ts new file mode 100644 index 00000000..0941c3dd --- /dev/null +++ b/cli/src/plugins/plugin-input-shared/index.ts @@ -0,0 +1,15 @@ +export { + AbstractInputPlugin +} from './AbstractInputPlugin' +export { + BaseDirectoryInputPlugin +} from './BaseDirectoryInputPlugin' +export type { + DirectoryInputPluginOptions +} from './BaseDirectoryInputPlugin' +export { + BaseFileInputPlugin +} from './BaseFileInputPlugin' +export type { + FileInputPluginOptions +} from './BaseFileInputPlugin' diff --git a/cli/src/plugins/plugin-input-shared/scope/GlobalScopeCollector.ts b/cli/src/plugins/plugin-input-shared/scope/GlobalScopeCollector.ts new file mode 100644 index 00000000..2a2c06ed --- /dev/null +++ b/cli/src/plugins/plugin-input-shared/scope/GlobalScopeCollector.ts @@ -0,0 +1,117 @@ +import type {EnvironmentContext, MdComponent, MdxGlobalScope, OsInfo, ToolReferences, UserProfile} from '@truenine/md-compiler/globals' // Collects and manages global scope variables for MDX expression evaluation. // src/scope/GlobalScopeCollector.ts +import type {UserConfigFile} from '@truenine/plugin-shared' +import * as os from 'node:os' +import process from 'node:process' +import {OsKind, ShellKind, ToolPresets} from '@truenine/md-compiler/globals' + +/** + * Tool preset names supported by GlobalScopeCollector + */ +export type ToolPresetName = keyof typeof ToolPresets + +/** + * Options for GlobalScopeCollector + */ +export interface GlobalScopeCollectorOptions { + /** User configuration file */ + readonly userConfig?: UserConfigFile | undefined + /** Tool preset to use (default: 'default') */ + readonly toolPreset?: ToolPresetName | undefined +} + +/** + * Collects global scope variables from system, environment, and user configuration. + * The collected scope is available in MDX templates via expressions like {os.platform}, {env.NODE_ENV}, etc. + */ +export class GlobalScopeCollector { + private readonly userConfig: UserConfigFile | undefined + private readonly toolPreset: ToolPresetName + + constructor(options: GlobalScopeCollectorOptions = {}) { + this.userConfig = options.userConfig + this.toolPreset = options.toolPreset ?? 'default' + } + + collect(): MdxGlobalScope { + return { + os: this.collectOsInfo(), + env: this.collectEnvContext(), + profile: this.collectProfile(), + tool: this.collectToolReferences(), + Md: this.createMdComponent() + } + } + + private collectOsInfo(): OsInfo { + const platform = os.platform() + return { + platform, + arch: os.arch(), + hostname: os.hostname(), + homedir: os.homedir(), + tmpdir: os.tmpdir(), + type: os.type(), + release: os.release(), + shellKind: this.detectShellKind(), + kind: this.detectOsKind(platform) + } + } + + private detectOsKind(platform: string): OsKind { + switch (platform) { + case 'win32': return OsKind.Win + case 'darwin': return OsKind.Mac + case 'linux': + case 'freebsd': + case 'openbsd': + case 'sunos': + case 'aix': return OsKind.Linux + default: return OsKind.Unknown + } + } + + private detectShellKind(): ShellKind { + const shell = process.env['SHELL'] ?? process.env['ComSpec'] ?? '' + const s = shell.toLowerCase() + + if (s.includes('bash')) return ShellKind.Bash + if (s.includes('zsh')) return ShellKind.Zsh + if (s.includes('fish')) return ShellKind.Fish + if (s.includes('pwsh')) return ShellKind.Pwsh + if (s.includes('powershell')) return ShellKind.PowerShell + if (s.includes('cmd')) return ShellKind.Cmd + if (s.endsWith('/sh')) return ShellKind.Sh + + return ShellKind.Unknown + } + + private collectEnvContext(): EnvironmentContext { + return {...process.env} + } + + private collectProfile(): UserProfile { + if (this.userConfig?.profile != null) return this.userConfig.profile as UserProfile + return {} + } + + private collectToolReferences(): ToolReferences { + const defaults: ToolReferences = {...ToolPresets.default} + if (this.toolPreset === 'claudeCode') return {...defaults, ...ToolPresets.claudeCode} + if (this.toolPreset === 'kiro') return {...defaults, ...ToolPresets.kiro} + return defaults + } + + private createMdComponent(): MdComponent { + const mdComponent = ((props: {when?: boolean, children?: unknown}) => { + if (props.when === false) return null + return props.children + }) as MdComponent + + mdComponent.Line = (props: {when?: boolean, children?: unknown}) => { + if (props.when === false) return null + return props.children + } + + return mdComponent + } +} diff --git a/cli/src/plugins/plugin-input-shared/scope/ScopeRegistry.ts b/cli/src/plugins/plugin-input-shared/scope/ScopeRegistry.ts new file mode 100644 index 00000000..45e5e951 --- /dev/null +++ b/cli/src/plugins/plugin-input-shared/scope/ScopeRegistry.ts @@ -0,0 +1,114 @@ +import type {EvaluationScope} from '@truenine/md-compiler' // Manages scope registration and merging with priority-based resolution. // src/scope/ScopeRegistry.ts +import type {MdxGlobalScope} from '@truenine/md-compiler/globals' + +/** + * Represents a single scope registration + */ +export interface ScopeRegistration { + readonly namespace: string + readonly values: Record + readonly priority: number +} + +/** + * Priority levels for scope sources. + * Higher values take precedence over lower values during merge. + */ +export enum ScopePriority { + /** System default values (os, default tool) */ + SystemDefault = 0, + /** Values from configuration file (profile, custom tool) */ + UserConfig = 10, + /** Values registered by plugins */ + PluginRegistered = 20, + /** Values passed at MDX compile time */ + CompileTime = 30 +} + +/** + * Registry for managing and merging scopes from multiple sources. + * Handles priority-based resolution when the same key exists in multiple sources. + */ +export class ScopeRegistry { + private readonly registrations: ScopeRegistration[] = [] + private globalScope: MdxGlobalScope | null = null + + setGlobalScope(scope: MdxGlobalScope): void { + this.globalScope = scope + } + + getGlobalScope(): MdxGlobalScope | null { + return this.globalScope + } + + register( + namespace: string, + values: Record, + priority: ScopePriority = ScopePriority.PluginRegistered + ): void { + this.registrations.push({namespace, values, priority}) + } + + getRegistrations(): readonly ScopeRegistration[] { + return this.registrations + } + + merge(compileTimeScope?: EvaluationScope): EvaluationScope { + const result: EvaluationScope = {} + + if (this.globalScope != null) { // 1. First add global scope (lowest priority) + result['os'] = {...this.globalScope.os} + result['env'] = {...this.globalScope.env} + result['profile'] = {...this.globalScope.profile} + result['tool'] = {...this.globalScope.tool} + } + + const sorted = [...this.registrations].sort((a, b) => a.priority - b.priority) // 2. Sort by priority and merge registered scopes + for (const reg of sorted) result[reg.namespace] = this.deepMerge(result[reg.namespace] as Record | undefined, reg.values) + + if (compileTimeScope != null) { // 3. Finally merge compile-time scope (highest priority) + for (const [key, value] of Object.entries(compileTimeScope)) { + result[key] = typeof value === 'object' && value !== null && !Array.isArray(value) + ? this.deepMerge(result[key] as Record | undefined, value as Record) + : value + } + } + + return result + } + + private deepMerge( + target: Record | undefined, + source: Record + ): Record { + if (target == null) return {...source} + + const result = {...target} + for (const [key, value] of Object.entries(source)) { + result[key] = typeof value === 'object' + && value !== null + && !Array.isArray(value) + && typeof result[key] === 'object' + && result[key] !== null + && !Array.isArray(result[key]) + ? this.deepMerge(result[key] as Record, value as Record) + : value + } + return result + } + + resolve(expression: string): string { + const scope = this.merge() + return expression.replaceAll(/\$\{([^}]+)\}/g, (_, key: string) => { + const parts = key.split('.') + let value: unknown = scope + for (const part of parts) value = (value as Record)?.[part] + return value != null ? String(value) : `\${${key}}` + }) + } + + clear(): void { + this.registrations.length = 0 + this.globalScope = null + } +} diff --git a/cli/src/plugins/plugin-input-shared/scope/index.ts b/cli/src/plugins/plugin-input-shared/scope/index.ts new file mode 100644 index 00000000..be015465 --- /dev/null +++ b/cli/src/plugins/plugin-input-shared/scope/index.ts @@ -0,0 +1,14 @@ +export { // Public API exports for the scope management module. // src/scope/index.ts + GlobalScopeCollector +} from './GlobalScopeCollector' +export type { + GlobalScopeCollectorOptions +} from './GlobalScopeCollector' + +export { + ScopePriority, + ScopeRegistry +} from './ScopeRegistry' +export type { + ScopeRegistration +} from './ScopeRegistry' diff --git a/cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.property.test.ts b/cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.property.test.ts new file mode 100644 index 00000000..0ec8b9f2 --- /dev/null +++ b/cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.property.test.ts @@ -0,0 +1,261 @@ +import type {InputEffectContext} from '@truenine/plugin-input-shared' +import type {ILogger, PluginOptions} from '@truenine/plugin-shared' + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import * as fc from 'fast-check' +import * as glob from 'fast-glob' +import {describe, expect, it} from 'vitest' +import {SkillNonSrcFileSyncEffectInputPlugin} from './SkillNonSrcFileSyncEffectInputPlugin' + +/** + * Feature: effect-input-plugins + * Property-based tests for SkillNonSrcFileSyncEffectInputPlugin + * + * Property 1: Non-.cn.mdx file sync correctness + * For any file in src/skills/{skill_name}/ that does not end with .cn.mdx, + * after the plugin executes, the file should exist at dist/skills/{skill_name}/{relative_path} + * with identical content. + * + * Property 3: Identical content skip (Idempotence) + * For any file that already exists at the destination with identical content to the source, + * running the plugin should not modify the destination file. + * + * Validates: Requirements 1.2, 1.4 + */ + +function createMockLogger(): ILogger { // Test helpers + return { + trace: () => { }, + debug: () => { }, + info: () => { }, + warn: () => { }, + error: () => { }, + fatal: () => { }, + child: () => createMockLogger() + } as unknown as ILogger +} + +function createEffectContext(workspaceDir: string, shadowProjectDir: string, dryRun: boolean = false): InputEffectContext { + return { + logger: createMockLogger(), + fs, + path, + glob, + userConfigOptions: {} as PluginOptions, + workspaceDir, + shadowProjectDir, + dryRun + } +} + +const validFileNameGen = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generators + .filter(s => /^[\w-]+$/.test(s)) + .map(s => s.toLowerCase()) + +const fileExtensionGen = fc.constantFrom('.ts', '.js', '.json', '.sh', '.txt', '.md', '.yaml', '.yml') + +const fileContentGen = fc.string({minLength: 0, maxLength: 1000}) + +const nonSrcMdFileNameGen = fc.tuple(validFileNameGen, fileExtensionGen) // Generate a non-.cn.mdx filename + .map(([name, ext]) => `${name}${ext}`) + .filter(name => !name.endsWith('.cn.mdx')) + +const srcMdFileNameGen = validFileNameGen.map(name => `${name}.cn.mdx`) // Generate a .cn.mdx filename + +interface SkillFile { // Generate skill directory structure + relativePath: string + content: string + isSrcMd: boolean +} + +interface SkillStructure { + skillName: string + files: SkillFile[] +} + +const skillStructureGen: fc.Arbitrary = fc.record({ + skillName: validFileNameGen, + files: fc.array( + fc.oneof( + fc.record({ // Non-.cn.mdx files (should be synced) + relativePath: nonSrcMdFileNameGen, + content: fileContentGen, + isSrcMd: fc.constant(false) + }), + fc.record({ // .cn.mdx files (should NOT be synced) + relativePath: srcMdFileNameGen, + content: fileContentGen, + isSrcMd: fc.constant(true) + }) + ), + {minLength: 1, maxLength: 5} + ) +}).map(skill => { + const seen = new Set() // Deduplicate files by relativePath, keeping the first occurrence + const uniqueFiles = skill.files.filter(file => { + if (seen.has(file.relativePath)) return false + seen.add(file.relativePath) + return true + }) + return {...skill, files: uniqueFiles} +}).filter(skill => skill.files.length > 0) + +describe('skillNonSrcFileSyncEffectInputPlugin Property Tests', () => { + describe('property 1: Non-.cn.mdx file sync correctness', () => { + it('should sync all non-.cn.mdx files from src/skills/ to dist/skills/ with identical content', {timeout: 60000}, async () => { + await fc.assert( + fc.asyncProperty( + fc.array(skillStructureGen, {minLength: 1, maxLength: 3}), + async skills => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-sync-p1-')) // Create isolated temp directory for this property run + + try { + const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create src/skills/ structure + const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') + const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') + + for (const skill of skills) { // Create skill directories and files + const skillDir = path.join(srcSkillsDir, skill.skillName) + fs.mkdirSync(skillDir, {recursive: true}) + + for (const file of skill.files) { + const filePath = path.join(skillDir, file.relativePath) + fs.mkdirSync(path.dirname(filePath), {recursive: true}) + fs.writeFileSync(filePath, file.content, 'utf8') + } + } + + const plugin = new SkillNonSrcFileSyncEffectInputPlugin() // Execute plugin + const ctx = createEffectContext(tempDir, shadowProjectDir, false) + const effectMethod = (plugin as any).syncNonSrcFiles.bind(plugin) + await effectMethod(ctx) + + for (const skill of skills) { // Verify: All non-.cn.mdx files should exist in dist with identical content + for (const file of skill.files) { + const distPath = path.join(distSkillsDir, skill.skillName, file.relativePath) + + if (file.isSrcMd) { + expect(fs.existsSync(distPath)).toBe(false) // .cn.mdx files should NOT be synced + } else { + expect(fs.existsSync(distPath)).toBe(true) // Non-.cn.mdx files should be synced with identical content + const distContent = fs.readFileSync(distPath, 'utf8') + expect(distContent).toBe(file.content) + } + } + } + } + finally { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup + } + } + ), + {numRuns: 100} + ) + }) + }) + + describe('property 3: Identical content skip (Idempotence)', () => { + it('should skip files with identical content and not modify them', async () => { + await fc.assert( + fc.asyncProperty( + skillStructureGen.filter(s => s.files.some(f => !f.isSrcMd)), + async skill => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-sync-p3a-')) // Create isolated temp directory for this property run + + try { + const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create src/skills/ and dist/skills/ with identical files + const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') + const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') + + const skillSrcDir = path.join(srcSkillsDir, skill.skillName) + const skillDistDir = path.join(distSkillsDir, skill.skillName) + + fs.mkdirSync(skillSrcDir, {recursive: true}) + fs.mkdirSync(skillDistDir, {recursive: true}) + + const nonSrcMdFiles = skill.files.filter(f => !f.isSrcMd) + + for (const file of nonSrcMdFiles) { // Create source files and pre-existing dist files with identical content + const srcPath = path.join(skillSrcDir, file.relativePath) + const distPath = path.join(skillDistDir, file.relativePath) + + fs.mkdirSync(path.dirname(srcPath), {recursive: true}) + fs.mkdirSync(path.dirname(distPath), {recursive: true}) + + fs.writeFileSync(srcPath, file.content, 'utf8') + fs.writeFileSync(distPath, file.content, 'utf8') + } + + const plugin = new SkillNonSrcFileSyncEffectInputPlugin() // Execute plugin + const ctx = createEffectContext(tempDir, shadowProjectDir, false) + const effectMethod = (plugin as any).syncNonSrcFiles.bind(plugin) + const result = await effectMethod(ctx) + + for (const file of nonSrcMdFiles) { // Verify: Files with identical content should be in skippedFiles + const distPath = path.join(skillDistDir, file.relativePath) + expect(result.skippedFiles).toContain(distPath) + expect(result.copiedFiles).not.toContain(distPath) + } + } + finally { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup + } + } + ), + {numRuns: 100} + ) + }) + + it('should be idempotent - running twice produces same result', async () => { + await fc.assert( + fc.asyncProperty( + skillStructureGen.filter(s => s.files.some(f => !f.isSrcMd)), + async skill => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-sync-p3b-')) // Create isolated temp directory for this property run + + try { + const shadowProjectDir = path.join(tempDir, 'shadow') // Setup + const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') + const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') + + const skillSrcDir = path.join(srcSkillsDir, skill.skillName) + fs.mkdirSync(skillSrcDir, {recursive: true}) + + for (const file of skill.files) { + const srcPath = path.join(skillSrcDir, file.relativePath) + fs.mkdirSync(path.dirname(srcPath), {recursive: true}) + fs.writeFileSync(srcPath, file.content, 'utf8') + } + + const plugin = new SkillNonSrcFileSyncEffectInputPlugin() // Execute plugin first time + const ctx = createEffectContext(tempDir, shadowProjectDir, false) + const effectMethod = (plugin as any).syncNonSrcFiles.bind(plugin) + await effectMethod(ctx) + + const result2 = await effectMethod(ctx) // Execute plugin second time + + const nonSrcMdFiles = skill.files.filter(f => !f.isSrcMd) // Verify: Second run should skip all files (idempotence) + expect(result2.copiedFiles.length).toBe(0) + expect(result2.skippedFiles.length).toBe(nonSrcMdFiles.length) + + for (const file of nonSrcMdFiles) { // Verify content is still identical + const srcPath = path.join(skillSrcDir, file.relativePath) + const distPath = path.join(distSkillsDir, skill.skillName, file.relativePath) + + const srcContent = fs.readFileSync(srcPath, 'utf8') + const distContent = fs.readFileSync(distPath, 'utf8') + expect(distContent).toBe(srcContent) + } + } + finally { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup + } + } + ), + {numRuns: 100} + ) + }) + }) +}) diff --git a/cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.ts b/cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.ts new file mode 100644 index 00000000..c78a5a12 --- /dev/null +++ b/cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.ts @@ -0,0 +1,182 @@ +import type {CollectedInputContext, InputEffectContext, InputEffectResult, InputPluginContext} from '@truenine/plugin-shared' + +import type {Buffer} from 'node:buffer' +import {createHash} from 'node:crypto' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' + +/** + * Result of the skill non-.cn.mdx file sync effect. + */ +export interface SkillSyncEffectResult extends InputEffectResult { + readonly copiedFiles: string[] + readonly skippedFiles: string[] + readonly createdDirs: string[] +} + +export class SkillNonSrcFileSyncEffectInputPlugin extends AbstractInputPlugin { + constructor() { + super('SkillNonSrcFileSyncEffectInputPlugin') + this.registerEffect('skill-non-src-file-sync', this.syncNonSrcFiles.bind(this), 10) + } + + private async syncNonSrcFiles(ctx: InputEffectContext): Promise { + const {fs, path, shadowProjectDir, dryRun, logger} = ctx + + const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') + const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') + + const copiedFiles: string[] = [] + const skippedFiles: string[] = [] + const createdDirs: string[] = [] + const errors: {path: string, error: Error}[] = [] + + if (!fs.existsSync(srcSkillsDir)) { + logger.debug({action: 'skill-sync', message: 'src/skills/ directory does not exist, skipping', srcSkillsDir}) + return { + success: true, + description: 'src/skills/ directory does not exist, nothing to sync', + copiedFiles, + skippedFiles, + createdDirs + } + } + + this.syncDirectoryRecursive( + ctx, + srcSkillsDir, + distSkillsDir, + '', + copiedFiles, + skippedFiles, + createdDirs, + errors, + dryRun ?? false + ) + + const hasErrors = errors.length > 0 + if (hasErrors) logger.warn({action: 'skill-sync', errors: errors.map(e => ({path: e.path, error: e.error.message}))}) + + return { + success: !hasErrors, + description: dryRun + ? `Would copy ${copiedFiles.length} files, skip ${skippedFiles.length} files` + : `Copied ${copiedFiles.length} files, skipped ${skippedFiles.length} files`, + copiedFiles, + skippedFiles, + createdDirs, + ...hasErrors && {error: new Error(`${errors.length} errors occurred during sync`)}, + modifiedFiles: copiedFiles + } + } + + private syncDirectoryRecursive( + ctx: InputEffectContext, + srcDir: string, + distDir: string, + relativePath: string, + copiedFiles: string[], + skippedFiles: string[], + createdDirs: string[], + errors: {path: string, error: Error}[], + dryRun: boolean + ): void { + const {fs, path, logger} = ctx + + const currentSrcDir = relativePath ? path.join(srcDir, relativePath) : srcDir + + if (!fs.existsSync(currentSrcDir)) return + + let entries: import('node:fs').Dirent[] + try { + entries = fs.readdirSync(currentSrcDir, {withFileTypes: true}) + } + catch (error) { + errors.push({path: currentSrcDir, error: error as Error}) + logger.warn({action: 'skill-sync', message: 'Failed to read directory', path: currentSrcDir, error: (error as Error).message}) + return + } + + for (const entry of entries) { + const entryRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name + const srcPath = path.join(srcDir, entryRelativePath) + const distPath = path.join(distDir, entryRelativePath) + + if (entry.isDirectory()) { + this.syncDirectoryRecursive( + ctx, + srcDir, + distDir, + entryRelativePath, + copiedFiles, + skippedFiles, + createdDirs, + errors, + dryRun + ) + } else if (entry.isFile()) { + if (entry.name.endsWith('.cn.mdx')) continue + + const targetDir = path.dirname(distPath) + if (!fs.existsSync(targetDir)) { + if (dryRun) { + logger.debug({action: 'skill-sync', dryRun: true, wouldCreateDir: targetDir}) + createdDirs.push(targetDir) + } else { + try { + fs.mkdirSync(targetDir, {recursive: true}) + createdDirs.push(targetDir) + logger.debug({action: 'skill-sync', createdDir: targetDir}) + } + catch (error) { + errors.push({path: targetDir, error: error as Error}) + logger.warn({action: 'skill-sync', message: 'Failed to create directory', path: targetDir, error: (error as Error).message}) + continue + } + } + } + + if (fs.existsSync(distPath)) { + try { + const srcContent = fs.readFileSync(srcPath) + const distContent = fs.readFileSync(distPath) + + const srcHash = this.computeHash(srcContent) + const distHash = this.computeHash(distContent) + + if (srcHash === distHash) { + skippedFiles.push(distPath) + logger.debug({action: 'skill-sync', skipped: distPath, reason: 'identical content'}) + continue + } + } + catch (error) { + logger.debug({action: 'skill-sync', message: 'Could not compare files, will copy', path: distPath, error: (error as Error).message}) + } + } + + if (dryRun) { + logger.debug({action: 'skill-sync', dryRun: true, wouldCopy: {from: srcPath, to: distPath}}) + copiedFiles.push(distPath) + } else { + try { + fs.copyFileSync(srcPath, distPath) + copiedFiles.push(distPath) + logger.debug({action: 'skill-sync', copied: {from: srcPath, to: distPath}}) + } + catch (error) { + errors.push({path: distPath, error: error as Error}) + logger.warn({action: 'skill-sync', message: 'Failed to copy file', from: srcPath, to: distPath, error: (error as Error).message}) + } + } + } + } + } + + private computeHash(content: Buffer): string { + return createHash('sha256').update(content).digest('hex') + } + + collect(_ctx: InputPluginContext): Partial { + return {} + } +} diff --git a/cli/src/plugins/plugin-input-skill-sync-effect/index.ts b/cli/src/plugins/plugin-input-skill-sync-effect/index.ts new file mode 100644 index 00000000..7b4c1a24 --- /dev/null +++ b/cli/src/plugins/plugin-input-skill-sync-effect/index.ts @@ -0,0 +1,6 @@ +export { + SkillNonSrcFileSyncEffectInputPlugin +} from './SkillNonSrcFileSyncEffectInputPlugin' +export type { + SkillSyncEffectResult +} from './SkillNonSrcFileSyncEffectInputPlugin' diff --git a/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.test.ts b/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.test.ts new file mode 100644 index 00000000..9873347b --- /dev/null +++ b/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.test.ts @@ -0,0 +1,137 @@ +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' +import {SubAgentInputPlugin} from './SubAgentInputPlugin' + +describe('subAgentInputPlugin', () => { + describe('extractSeriesInfo', () => { + const plugin = new SubAgentInputPlugin() + + it('should derive series from parentDirName when provided', () => { + const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z0-9]+$/i.test(s)) + + const alphanumericAgentName = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^[\w-]+$/.test(s)) + + fc.assert( + fc.property( + alphanumericNoUnderscore, + alphanumericAgentName, + (parentDir, agentName) => { + const fileName = `${agentName}.mdx` + const result = plugin.extractSeriesInfo(fileName, parentDir) + + expect(result.series).toBe(parentDir) + expect(result.agentName).toBe(agentName) + } + ), + {numRuns: 100} + ) + }) + + it('should handle explore/deep.cn.mdx subdirectory format', () => { + const result = plugin.extractSeriesInfo('deep.cn.mdx', 'explore') + expect(result.series).toBe('explore') + expect(result.agentName).toBe('deep.cn') + }) + + it('should handle context/gatherer.cn.mdx subdirectory format', () => { + const result = plugin.extractSeriesInfo('gatherer.cn.mdx', 'context') + expect(result.series).toBe('context') + expect(result.agentName).toBe('gatherer.cn') + }) + + it('should extract series as substring before first underscore for filenames with underscore', () => { + const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z0-9]+$/i.test(s)) + + const alphanumericWithUnderscore = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^\w+$/.test(s)) + + fc.assert( + fc.property( + alphanumericNoUnderscore, + alphanumericWithUnderscore, + (seriesPrefix, agentName) => { + const fileName = `${seriesPrefix}_${agentName}.mdx` + const result = plugin.extractSeriesInfo(fileName) + + expect(result.series).toBe(seriesPrefix) + expect(result.agentName).toBe(agentName) + } + ), + {numRuns: 100} + ) + }) + + it('should return undefined series for filenames without underscore', () => { + const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z0-9]+$/i.test(s)) + + fc.assert( + fc.property( + alphanumericNoUnderscore, + baseName => { + const fileName = `${baseName}.mdx` + const result = plugin.extractSeriesInfo(fileName) + + expect(result.series).toBeUndefined() + expect(result.agentName).toBe(baseName) + } + ), + {numRuns: 100} + ) + }) + + it('should use only first underscore as delimiter', () => { + const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z0-9]+$/i.test(s)) + + fc.assert( + fc.property( + alphanumericNoUnderscore, + alphanumericNoUnderscore, + alphanumericNoUnderscore, + (seriesPrefix, part1, part2) => { + const fileName = `${seriesPrefix}_${part1}_${part2}.mdx` + const result = plugin.extractSeriesInfo(fileName) + + expect(result.series).toBe(seriesPrefix) + expect(result.agentName).toBe(`${part1}_${part2}`) + } + ), + {numRuns: 100} + ) + }) + + it('should handle explore_deep.mdx correctly', () => { + const result = plugin.extractSeriesInfo('explore_deep.mdx') + expect(result.series).toBe('explore') + expect(result.agentName).toBe('deep') + }) + + it('should handle simple.mdx correctly (no underscore)', () => { + const result = plugin.extractSeriesInfo('simple.mdx') + expect(result.series).toBeUndefined() + expect(result.agentName).toBe('simple') + }) + + it('should handle explore_deep_search.mdx correctly (multiple underscores)', () => { + const result = plugin.extractSeriesInfo('explore_deep_search.mdx') + expect(result.series).toBe('explore') + expect(result.agentName).toBe('deep_search') + }) + + it('should handle _agent.mdx correctly (empty prefix)', () => { + const result = plugin.extractSeriesInfo('_agent.mdx') + expect(result.series).toBe('') + expect(result.agentName).toBe('agent') + }) + + it('should prioritize parentDirName over underscore naming', () => { + const result = plugin.extractSeriesInfo('explore_deep.mdx', 'context') + expect(result.series).toBe('context') + expect(result.agentName).toBe('explore_deep') + }) + }) +}) diff --git a/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts b/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts new file mode 100644 index 00000000..132391b8 --- /dev/null +++ b/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts @@ -0,0 +1,200 @@ +import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' +import type { + CollectedInputContext, + InputPluginContext, + MetadataValidationResult, + PluginOptions, + ResolvedBasePaths, + SubAgentPrompt, + SubAgentYAMLFrontMatter +} from '@truenine/plugin-shared' +import {mdxToMd} from '@truenine/md-compiler' +import {MetadataValidationError} from '@truenine/md-compiler/errors' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import {BaseDirectoryInputPlugin} from '@truenine/plugin-input-shared' +import { + FilePathKind, + PromptKind, + validateSubAgentMetadata +} from '@truenine/plugin-shared' + +export interface SubAgentSeriesInfo { + readonly series?: string + readonly agentName: string +} + +export class SubAgentInputPlugin extends BaseDirectoryInputPlugin { + constructor() { + super('SubAgentInputPlugin', {configKey: 'shadowSourceProject.subAgent.dist'}) + } + + protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + return this.resolveShadowPath(options.shadowSourceProject.subAgent.dist, resolvedPaths.shadowProjectDir) + } + + protected validateMetadata(metadata: Record, filePath: string): MetadataValidationResult { + return validateSubAgentMetadata(metadata, filePath) + } + + protected createResult(items: SubAgentPrompt[]): Partial { + return {subAgents: items} + } + + extractSeriesInfo(fileName: string, parentDirName?: string): SubAgentSeriesInfo { + const baseName = fileName.replace(/\.mdx$/, '') + + if (parentDirName != null) { + return { + series: parentDirName, + agentName: baseName + } + } + + const underscoreIndex = baseName.indexOf('_') + + if (underscoreIndex === -1) return {agentName: baseName} + + return { + series: baseName.slice(0, Math.max(0, underscoreIndex)), + agentName: baseName.slice(Math.max(0, underscoreIndex + 1)) + } + } + + 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: SubAgentPrompt[] = [] + + 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.isFile() && entry.name.endsWith(this.extension)) { + const prompt = await this.processFile(entry.name, path.join(targetDir, entry.name), targetDir, void 0, ctx) + if (prompt != null) items.push(prompt) + } else 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 | undefined, + 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: parentDirName != null ? ctx.path.join(baseDir, parentDirName) : baseDir + }) + + const mergedFrontMatter: SubAgentYAMLFrontMatter | undefined = parsed.yamlFrontMatter != null || Object.keys(compileResult.metadata.fields).length > 0 + ? { + ...parsed.yamlFrontMatter, + ...compileResult.metadata.fields + } as SubAgentYAMLFrontMatter + : 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 != null ? `${parentDirName}/${fileName}` : 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 + } + } + + protected createPrompt( + entryName: string, + filePath: string, + content: string, + yamlFrontMatter: SubAgentYAMLFrontMatter | undefined, + rawFrontMatter: string | undefined, + parsed: ParsedMarkdown, + baseDir: string, + rawContent: string + ): SubAgentPrompt { + const slashIndex = entryName.indexOf('/') + const parentDirName = slashIndex !== -1 ? entryName.slice(0, slashIndex) : void 0 + const fileName = slashIndex !== -1 ? entryName.slice(slashIndex + 1) : entryName + + const seriesInfo = this.extractSeriesInfo(fileName, parentDirName) + const seriName = yamlFrontMatter?.seriName + + return { + type: PromptKind.SubAgent, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + ...yamlFrontMatter != null && {yamlFrontMatter}, + ...rawFrontMatter != null && {rawFrontMatter}, + markdownAst: parsed.markdownAst, + markdownContents: parsed.markdownContents, + dir: { + pathKind: FilePathKind.Relative, + path: entryName, + basePath: baseDir, + getDirectoryName: () => entryName.replace(/\.mdx$/, ''), + getAbsolutePath: () => filePath + }, + ...seriesInfo.series != null && {series: seriesInfo.series}, + agentName: seriesInfo.agentName, + ...seriName != null && {seriName}, + rawMdxContent: rawContent + } + } +} diff --git a/cli/src/plugins/plugin-input-subagent/index.ts b/cli/src/plugins/plugin-input-subagent/index.ts new file mode 100644 index 00000000..055e0454 --- /dev/null +++ b/cli/src/plugins/plugin-input-subagent/index.ts @@ -0,0 +1,6 @@ +export { + SubAgentInputPlugin +} from './SubAgentInputPlugin' +export type { + SubAgentSeriesInfo +} from './SubAgentInputPlugin' diff --git a/cli/src/plugins/plugin-input-vscode-config/VSCodeConfigInputPlugin.ts b/cli/src/plugins/plugin-input-vscode-config/VSCodeConfigInputPlugin.ts new file mode 100644 index 00000000..c975e526 --- /dev/null +++ b/cli/src/plugins/plugin-input-vscode-config/VSCodeConfigInputPlugin.ts @@ -0,0 +1,48 @@ +import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '@truenine/plugin-shared' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' +import {FilePathKind, IDEKind} from '@truenine/plugin-shared' + +function readIdeConfigFile( + type: T, + relativePath: string, + shadowProjectDir: string, + fs: typeof import('node:fs'), + path: typeof import('node:path') +): ProjectIDEConfigFile | undefined { + const absPath = path.join(shadowProjectDir, relativePath) + if (!(fs.existsSync(absPath) && fs.statSync(absPath).isFile())) return void 0 + + const content = fs.readFileSync(absPath, 'utf8') + return { + type, + content, + length: content.length, + filePathKind: FilePathKind.Absolute, + dir: { + pathKind: FilePathKind.Absolute, + path: absPath, + getDirectoryName: () => path.basename(absPath) + } + } +} + +export class VSCodeConfigInputPlugin extends AbstractInputPlugin { + constructor() { + super('VSCodeConfigInputPlugin') + } + + collect(ctx: InputPluginContext): Partial { + const {userConfigOptions, fs, path} = ctx + const {shadowProjectDir} = this.resolveBasePaths(userConfigOptions) + + const files = ['.vscode/settings.json', '.vscode/extensions.json'] + const vscodeConfigFiles: ProjectIDEConfigFile[] = [] + + for (const relativePath of files) { + const file = readIdeConfigFile(IDEKind.VSCode, relativePath, shadowProjectDir, fs, path) + if (file != null) vscodeConfigFiles.push(file) + } + + return {vscodeConfigFiles} + } +} diff --git a/cli/src/plugins/plugin-input-vscode-config/index.ts b/cli/src/plugins/plugin-input-vscode-config/index.ts new file mode 100644 index 00000000..0d16869b --- /dev/null +++ b/cli/src/plugins/plugin-input-vscode-config/index.ts @@ -0,0 +1,3 @@ +export { + VSCodeConfigInputPlugin +} from './VSCodeConfigInputPlugin' diff --git a/cli/src/plugins/plugin-input-workspace/WorkspaceInputPlugin.ts b/cli/src/plugins/plugin-input-workspace/WorkspaceInputPlugin.ts new file mode 100644 index 00000000..2c46ad25 --- /dev/null +++ b/cli/src/plugins/plugin-input-workspace/WorkspaceInputPlugin.ts @@ -0,0 +1,31 @@ +import type {CollectedInputContext, InputPluginContext, Workspace} from '@truenine/plugin-shared' +import * as path from 'node:path' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' +import { + FilePathKind +} from '@truenine/plugin-shared' + +export class WorkspaceInputPlugin extends AbstractInputPlugin { + constructor() { + super('WorkspaceInputPlugin') + } + + collect(ctx: InputPluginContext): Partial { + const {userConfigOptions: options} = ctx + const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(options) + + const workspace: Workspace = { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceDir, + getDirectoryName: () => path.basename(workspaceDir) + }, + projects: [] + } + + return { + workspace, + shadowSourceProjectDir: shadowProjectDir + } + } +} diff --git a/cli/src/plugins/plugin-input-workspace/index.ts b/cli/src/plugins/plugin-input-workspace/index.ts new file mode 100644 index 00000000..10051289 --- /dev/null +++ b/cli/src/plugins/plugin-input-workspace/index.ts @@ -0,0 +1,3 @@ +export { + WorkspaceInputPlugin +} from './WorkspaceInputPlugin' diff --git a/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.test.ts b/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.test.ts new file mode 100644 index 00000000..bb7fff4a --- /dev/null +++ b/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.test.ts @@ -0,0 +1,391 @@ +import type { + CollectedInputContext, + FastCommandPrompt, + GlobalMemoryPrompt, + OutputPluginContext, + OutputWriteContext, + ProjectChildrenMemoryPrompt, + ProjectRootMemoryPrompt, + RelativePath, + SkillPrompt +} from '@truenine/plugin-shared' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import * as deskPaths from '@truenine/desk-paths' +import {FilePathKind, PromptKind} from '@truenine/plugin-shared' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {JetBrainsAIAssistantCodexOutputPlugin} from './JetBrainsAIAssistantCodexOutputPlugin' + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { + return { + pathKind: FilePathKind.Relative, + path: pathStr, + basePath, + getDirectoryName: () => path.basename(pathStr), + getAbsolutePath: () => path.join(basePath, pathStr) + } +} + +function createMockRootPath(pathStr: string): {pathKind: FilePathKind.Root, path: string, getDirectoryName: () => string} { + return { + pathKind: FilePathKind.Root, + path: pathStr, + getDirectoryName: () => path.basename(pathStr) + } +} + +function createGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt { + return { + type: PromptKind.GlobalMemory, + content, + dir: createMockRelativePath('.', basePath), + markdownContents: [], + length: content.length, + filePathKind: FilePathKind.Relative, + parentDirectoryPath: { + type: 'UserHome', + directory: createMockRelativePath('.memory', basePath) + } + } as GlobalMemoryPrompt +} + +function createProjectRootMemoryPrompt(content: string, basePath: string): ProjectRootMemoryPrompt { + return { + type: PromptKind.ProjectRootMemory, + content, + dir: createMockRootPath(path.join(basePath, 'project')), + markdownContents: [], + length: content.length, + filePathKind: FilePathKind.Relative + } as ProjectRootMemoryPrompt +} + +function createProjectChildMemoryPrompt( + basePath: string, + dirPath: string, + content: string +): ProjectChildrenMemoryPrompt { + return { + type: PromptKind.ProjectChildrenMemory, + content, + dir: createMockRelativePath(dirPath, basePath), + markdownContents: [], + length: content.length, + filePathKind: FilePathKind.Relative, + workingChildDirectoryPath: createMockRelativePath(dirPath, basePath) + } as ProjectChildrenMemoryPrompt +} + +function createFastCommandPrompt( + basePath: string, + series: string | undefined, + commandName: string, + content: string, + rawFrontMatter?: string +): FastCommandPrompt { + return { + type: PromptKind.FastCommand, + series, + commandName, + content, + rawFrontMatter, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', basePath), + markdownContents: [] + } as FastCommandPrompt +} + +function createSkillPrompt(basePath: string, name: string, description: string): SkillPrompt { + return { + type: PromptKind.Skill, + yamlFrontMatter: { + name, + description, + displayName: 'Display Name', + version: '1.2.3', + author: 'Test Author', + keywords: ['alpha', 'beta'], + allowTools: ['toolA', 'toolB'] + }, + content: '# Skill Body', + length: 12, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('skill', basePath), + markdownContents: [], + childDocs: [ + { + type: PromptKind.SkillChildDoc, + dir: createMockRelativePath('references/guide.mdx', basePath), + content: '# Guide', + markdownContents: [], + length: 7, + filePathKind: FilePathKind.Relative + } + ], + resources: [ + { + type: PromptKind.SkillResource, + extension: '.txt', + fileName: 'notes.txt', + relativePath: 'assets/notes.txt', + content: 'resource-content', + encoding: 'text', + category: 'document', + length: 16 + } + ] + } as SkillPrompt +} + +function createMockOutputContext( + basePath: string, + collectedInputContext: Partial, + dryRun = false +): OutputWriteContext { + return { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', basePath), + projects: [] + }, + ideConfigFiles: [], + ...collectedInputContext + } as CollectedInputContext, + dryRun + } +} + +function createJetBrainsCodexDir(basePath: string, ideName: string): string { + const codexDir = path.join(basePath, 'JetBrains', ideName, 'aia', 'codex') + fs.mkdirSync(codexDir, {recursive: true}) + return codexDir +} + +describe('jetBrainsAIAssistantCodexOutputPlugin', () => { + let tempDir: string, + plugin: JetBrainsAIAssistantCodexOutputPlugin + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jb-codex-test-')) + vi.spyOn(deskPaths, 'getPlatformFixedDir').mockReturnValue(tempDir) + plugin = new JetBrainsAIAssistantCodexOutputPlugin() + }) + + afterEach(() => { + vi.clearAllMocks() + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) + }) + + describe('registerGlobalOutputDirs', () => { + it('should register prompts and skill directories for supported IDEs', async () => { + createJetBrainsCodexDir(tempDir, 'IntelliJIdea2025.3') + createJetBrainsCodexDir(tempDir, 'WebStorm2025.1') + createJetBrainsCodexDir(tempDir, 'OtherIDE2025.1') + + const ctx: OutputPluginContext = { + collectedInputContext: { + workspace: {directory: createMockRelativePath('.', tempDir), projects: []}, + ideConfigFiles: [], + skills: [createSkillPrompt(tempDir, 'alpha-skill', 'alpha description')] + } as CollectedInputContext + } + + const results = await plugin.registerGlobalOutputDirs(ctx) + + const promptsDirs = results.filter(item => item.path === 'prompts') + const skillDirs = results.filter(item => item.path.endsWith(path.join('skills', 'alpha-skill'))) + + expect(promptsDirs).toHaveLength(2) + expect(skillDirs).toHaveLength(2) + expect(results.some(item => item.basePath.includes('OtherIDE'))).toBe(false) + }) + }) + + describe('registerGlobalOutputFiles', () => { + it('should register AGENTS.md for each supported IDE codex directory', async () => { + const ideaDir = createJetBrainsCodexDir(tempDir, 'IntelliJIdea2025.3') + const webstormDir = createJetBrainsCodexDir(tempDir, 'WebStorm2025.1') + + const results = await plugin.registerGlobalOutputFiles() + + expect(results).toHaveLength(2) + expect(results.map(r => r.getAbsolutePath())).toContain(path.join(ideaDir, 'AGENTS.md')) + expect(results.map(r => r.getAbsolutePath())).toContain(path.join(webstormDir, 'AGENTS.md')) + }) + }) + + describe('canWrite', () => { + it('should return false when no outputs exist', async () => { + const ctx = createMockOutputContext(tempDir, {}) + + const result = await plugin.canWrite(ctx) + + expect(result).toBe(false) + }) + + it('should return true when global memory is present', async () => { + const ctx = createMockOutputContext(tempDir, { + globalMemory: createGlobalMemoryPrompt('global', tempDir) + }) + + const result = await plugin.canWrite(ctx) + + expect(result).toBe(true) + }) + + it('should return true when project prompts are present', async () => { + const projectDir = createMockRelativePath('project-a', tempDir) + const ctx = createMockOutputContext(tempDir, { + workspace: { + directory: createMockRelativePath('.', tempDir), + projects: [ + { + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: createProjectRootMemoryPrompt('root', tempDir), + childMemoryPrompts: [createProjectChildMemoryPrompt(tempDir, 'src', 'child')] + } + ] + } + }) + + const result = await plugin.canWrite(ctx) + + expect(result).toBe(true) + }) + }) + + describe('writeGlobalOutputs', () => { + it('should not write files during dry-run', async () => { + const codexDir = createJetBrainsCodexDir(tempDir, 'IntelliJIdea2025.3') + const ctx = createMockOutputContext( + tempDir, + { + globalMemory: createGlobalMemoryPrompt('global', tempDir), + fastCommands: [createFastCommandPrompt(tempDir, 'spec', 'build', 'body', 'title: Dry')], + skills: [createSkillPrompt(tempDir, 'dry-skill', 'dry description')] + }, + true + ) + + const result = await plugin.writeGlobalOutputs(ctx) + + expect(result.files.length).toBe(3) + expect(fs.existsSync(path.join(codexDir, 'AGENTS.md'))).toBe(false) + }) + + it('should write global memory, commands, and skills for each IDE', async () => { + const ideaDir = createJetBrainsCodexDir(tempDir, 'IntelliJIdea2025.3') + const webstormDir = createJetBrainsCodexDir(tempDir, 'WebStorm2025.1') + createJetBrainsCodexDir(tempDir, 'OtherIDE2025.1') + + const globalContent = 'GLOBAL MEMORY' + const fastCommand = createFastCommandPrompt(tempDir, 'spec', 'compile', 'command-body', 'title: Compile') + const skillName = 'My Skill !!!' + const skillDescription = 'Line 1\nLine 2' + const skill = createSkillPrompt(tempDir, skillName, skillDescription) + + const ctx = createMockOutputContext(tempDir, { + globalMemory: createGlobalMemoryPrompt(globalContent, tempDir), + fastCommands: [fastCommand], + skills: [skill] + }) + + const result = await plugin.writeGlobalOutputs(ctx) + + expect(result.files.length).toBeGreaterThan(0) + + const ideaAgents = path.join(ideaDir, 'AGENTS.md') + const webstormAgents = path.join(webstormDir, 'AGENTS.md') + expect(fs.readFileSync(ideaAgents, 'utf8')).toBe(globalContent) + expect(fs.readFileSync(webstormAgents, 'utf8')).toBe(globalContent) + + const commandFile = path.join(ideaDir, 'prompts', 'spec-compile.md') + const commandContent = fs.readFileSync(commandFile, 'utf8') + expect(commandContent).toContain('---') + expect(commandContent).toContain('title: Compile') + expect(commandContent).toContain('command-body') + + const skillDir = path.join(ideaDir, 'skills', skillName) + const skillFile = path.join(skillDir, 'SKILL.md') + const skillContent = fs.readFileSync(skillFile, 'utf8') + expect(skillContent).toContain('name: my-skill') + expect(skillContent).toContain('description: Line 1 Line 2') + expect(skillContent).toContain('allowed-tools: toolA toolB') + expect(skillContent).toContain('# Skill Body') + + const refFile = path.join(skillDir, 'references', 'guide.md') + expect(fs.readFileSync(refFile, 'utf8')).toBe('# Guide') + + const resourceFile = path.join(skillDir, 'assets', 'notes.txt') + expect(fs.readFileSync(resourceFile, 'utf8')).toBe('resource-content') + + const otherAgents = path.join(tempDir, 'JetBrains', 'OtherIDE2025.1', 'aia', 'codex', 'AGENTS.md') + expect(fs.existsSync(otherAgents)).toBe(false) + }) + }) + + describe('writeProjectOutputs', () => { + it('should write always and glob rules for project prompts', async () => { + const projectDir = createMockRelativePath('project-a', tempDir) + const rootContent = 'ROOT MEMORY' + const childContent = 'CHILD MEMORY' + const ctx = createMockOutputContext(tempDir, { + workspace: { + directory: createMockRelativePath('.', tempDir), + projects: [ + { + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: createProjectRootMemoryPrompt(rootContent, tempDir), + childMemoryPrompts: [createProjectChildMemoryPrompt(tempDir, 'src', childContent)] + } + ] + } + }) + + const result = await plugin.writeProjectOutputs(ctx) + + expect(result.files.length).toBe(2) + + const rulesDir = path.join(tempDir, 'project-a', '.aiassistant', 'rules') + const rootFile = path.join(rulesDir, 'always.md') + const childFile = path.join(rulesDir, 'glob-src.md') + + const rootWritten = fs.readFileSync(rootFile, 'utf8') + expect(rootWritten).toContain('\u59CB\u7EC8') + expect(rootWritten).toContain(rootContent) + + const childWritten = fs.readFileSync(childFile, 'utf8') + expect(childWritten).toContain('\u6309\u6587\u4EF6\u6A21\u5F0F') + expect(childWritten).toContain('\u6A21\u5F0F') + expect(childWritten).toContain('src/**') + expect(childWritten).toContain(childContent) + }) + + it('should skip writes on dry-run for project prompts', async () => { + const projectDir = createMockRelativePath('project-a', tempDir) + const ctx = createMockOutputContext( + tempDir, + { + workspace: { + directory: createMockRelativePath('.', tempDir), + projects: [ + { + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: createProjectRootMemoryPrompt('root', tempDir), + childMemoryPrompts: [createProjectChildMemoryPrompt(tempDir, 'src', 'child')] + } + ] + } + }, + true + ) + + const result = await plugin.writeProjectOutputs(ctx) + + expect(result.files.length).toBe(2) + expect(fs.existsSync(path.join(tempDir, 'project-a', '.aiassistant', 'rules', 'always.md'))).toBe(false) + }) + }) +}) diff --git a/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts b/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts new file mode 100644 index 00000000..ac48d070 --- /dev/null +++ b/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts @@ -0,0 +1,607 @@ +import type { + FastCommandPrompt, + OutputPluginContext, + OutputWriteContext, + Project, + ProjectChildrenMemoryPrompt, + SkillPrompt, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import * as fs from 'node:fs' +import * as path from 'node:path' +import {getPlatformFixedDir} from '@truenine/desk-paths' +import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' + +/** + * Represents the filename of the project memory file. + */ +const PROJECT_MEMORY_FILE = 'AGENTS.md' +/** + * Specifies the name of the subdirectory where prompt files are stored. + */ +const PROMPTS_SUBDIR = 'prompts' +/** + * Represents the name of the subdirectory where skill-related resources are stored. + */ +const SKILLS_SUBDIR = 'skills' +/** + * The file name that represents the skill definition file. + */ +const SKILL_FILE_NAME = 'SKILL.md' +const AIASSISTANT_DIR = '.aiassistant' +const RULES_SUBDIR = 'rules' +const ROOT_RULE_FILE = 'always.md' +const CHILD_RULE_FILE_PREFIX = 'glob-' +const RULE_APPLY_ALWAYS = '\u59CB\u7EC8' +const RULE_APPLY_GLOB = '\u6309\u6587\u4EF6\u6A21\u5F0F' +const RULE_GLOB_KEY = '\u6A21\u5F0F' +/** + * Represents the directory name used for storing JetBrains-related resources or files. + */ +const JETBRAINS_VENDOR_DIR = 'JetBrains' +/** + * Represents the directory path where the AIA files are stored. + */ +const AIA_DIR = 'aia' +/** + * Represents the directory path where the Codex-related files are stored. + */ +const CODEX_DIR = 'codex' + +/** + * An array of constant string literals representing the prefixes of JetBrains IDE directory names. + */ +const IDE_DIR_PREFIXES = [ + 'IntelliJIdea', + 'WebStorm', + 'RustRover', + 'PyCharm', + 'PyCharmCE', + 'PhpStorm', + 'GoLand', + 'CLion', + 'DataGrip', + 'RubyMine', + 'Rider', + 'DataSpell', + 'Aqua' +] as const + +/** + * Represents an output plugin specifically designed for integration with JetBrains AI Assistant Codex. + */ +export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('JetBrainsAIAssistantCodexOutputPlugin', { + outputFileName: PROJECT_MEMORY_FILE, + dependsOn: [PLUGIN_NAMES.AgentsOutput], + indexignore: '.aiignore' + }) + } + + async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + + for (const project of projects) { + if (project.dirFromWorkspacePath == null) continue + results.push(this.createProjectRulesDirRelativePath(project.dirFromWorkspacePath)) + } + + return results + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + + if (project.rootMemoryPrompt != null) results.push(this.createProjectRuleFileRelativePath(projectDir, ROOT_RULE_FILE)) + + if (project.childMemoryPrompts != null) { + for (const child of project.childMemoryPrompts) { + const fileName = this.buildChildRuleFileName(child) + results.push(this.createProjectRuleFileRelativePath(projectDir, fileName)) + } + } + } + + results.push(...this.registerProjectIgnoreOutputFiles(projects)) + return results + } + + async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const codexDirs = this.resolveCodexDirs() + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + + for (const codexDir of codexDirs) { + const promptsPath = path.join(codexDir, PROMPTS_SUBDIR) + results.push({ + pathKind: FilePathKind.Relative, + path: PROMPTS_SUBDIR, + basePath: codexDir, + getDirectoryName: () => PROMPTS_SUBDIR, + getAbsolutePath: () => promptsPath + }) + + const {skills} = ctx.collectedInputContext + if (skills == null || skills.length === 0) continue + + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const skillPath = path.join(codexDir, SKILLS_SUBDIR, skillName) + results.push({ + pathKind: FilePathKind.Relative, + path: path.join(SKILLS_SUBDIR, skillName), + basePath: codexDir, + getDirectoryName: () => skillName, + getAbsolutePath: () => skillPath + }) + } + } + + return results + } + + async registerGlobalOutputFiles(): Promise { + const codexDirs = this.resolveCodexDirs() + return codexDirs.map(codexDir => ({ + pathKind: FilePathKind.Relative, + path: PROJECT_MEMORY_FILE, + basePath: codexDir, + getDirectoryName: () => CODEX_DIR, + getAbsolutePath: () => path.join(codexDir, PROJECT_MEMORY_FILE) + })) + } + + async canWrite(ctx: OutputWriteContext): Promise { + 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 || hasAiIgnore) return true + + this.log.trace({action: 'skip', reason: 'noOutputs'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + + if (project.rootMemoryPrompt != null) { + const content = this.buildAlwaysRuleContent(project.rootMemoryPrompt.content as string) + const result = await this.writeProjectRuleFile(ctx, project, ROOT_RULE_FILE, content, 'projectRootRule') + fileResults.push(result) + } + + if (project.childMemoryPrompts != null) { + for (const child of project.childMemoryPrompts) { + const fileName = this.buildChildRuleFileName(child) + const content = this.buildGlobRuleContent(child) + const result = await this.writeProjectRuleFile(ctx, project, fileName, content, 'projectChildRule') + fileResults.push(result) + } + } + } + + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) + + return {files: fileResults, dirs: dirResults} + } + + async writeGlobalOutputs(ctx: OutputWriteContext): Promise { + const {globalMemory, fastCommands, skills} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + const codexDirs = this.resolveCodexDirs() + + if (codexDirs.length === 0) return {files: fileResults, dirs: dirResults} + + const filteredCommands = fastCommands != null ? filterCommandsByProjectConfig(fastCommands, projectConfig) : [] + const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] + + for (const codexDir of codexDirs) { + if (globalMemory != null) { + const fullPath = path.join(codexDir, PROJECT_MEMORY_FILE) + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: PROJECT_MEMORY_FILE, + basePath: codexDir, + getDirectoryName: () => CODEX_DIR, + getAbsolutePath: () => fullPath + } + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'globalMemory', path: fullPath}) + fileResults.push({path: relativePath, success: true, skipped: false}) + } else { + try { + this.ensureDirectory(codexDir) + fs.writeFileSync(fullPath, globalMemory.content as string, 'utf8') + this.log.trace({action: 'write', type: 'globalMemory', path: fullPath}) + fileResults.push({path: relativePath, success: true}) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'globalMemory', path: fullPath, error: errMsg}) + fileResults.push({path: relativePath, success: false, error: error as Error}) + } + } + } + + if (filteredCommands.length > 0) { + for (const cmd of filteredCommands) { + const cmdResults = await this.writeGlobalFastCommand(ctx, codexDir, cmd) + fileResults.push(...cmdResults) + } + } + + if (filteredSkills.length === 0) continue + + for (const skill of filteredSkills) { + const skillResults = await this.writeGlobalSkill(ctx, codexDir, skill) + fileResults.push(...skillResults) + } + } + + return {files: fileResults, dirs: dirResults} + } + + private resolveCodexDirs(): string[] { + const baseDir = path.join(getPlatformFixedDir(), JETBRAINS_VENDOR_DIR) + if (!this.existsSync(baseDir)) return [] + + try { + const dirents = this.readdirSync(baseDir, {withFileTypes: true}) + const ideDirs = dirents.filter(dirent => { + if (!dirent.isDirectory()) return false + return this.isSupportedIdeDir(dirent.name) + }) + return ideDirs.map(dirent => path.join(baseDir, dirent.name, AIA_DIR, CODEX_DIR)) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.warn({action: 'scan', type: 'jetbrains', path: baseDir, error: errMsg}) + return [] + } + } + + private createProjectRulesDirRelativePath(projectDir: RelativePath): RelativePath { + const rulesDirPath = path.join(projectDir.path, AIASSISTANT_DIR, RULES_SUBDIR) + return { + pathKind: FilePathKind.Relative, + path: rulesDirPath, + basePath: projectDir.basePath, + getDirectoryName: () => RULES_SUBDIR, + getAbsolutePath: () => path.join(projectDir.basePath, rulesDirPath) + } + } + + private createProjectRuleFileRelativePath(projectDir: RelativePath, fileName: string): RelativePath { + const filePath = path.join(projectDir.path, AIASSISTANT_DIR, RULES_SUBDIR, fileName) + return { + pathKind: FilePathKind.Relative, + path: filePath, + basePath: projectDir.basePath, + getDirectoryName: () => RULES_SUBDIR, + getAbsolutePath: () => path.join(projectDir.basePath, filePath) + } + } + + private buildChildRuleFileName(child: ProjectChildrenMemoryPrompt): string { + const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path + const normalizedPath = childPath + .replaceAll('\\', '/') + .replaceAll(/^\/+|\/+$/g, '') + .replaceAll('/', '-') + + const suffix = normalizedPath.length > 0 ? normalizedPath : 'root' + return `${CHILD_RULE_FILE_PREFIX}${suffix}.md` + } + + private buildChildRulePattern(child: ProjectChildrenMemoryPrompt): string { + const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path + const normalizedPath = childPath + .replaceAll('\\', '/') + .replaceAll(/^\/+|\/+$/g, '') + + if (normalizedPath.length === 0) return '**/*' + return `${normalizedPath}/**` + } + + private buildAlwaysRuleContent(content: string): string { + const fmData: Record = { + apply: RULE_APPLY_ALWAYS + } + + return buildMarkdownWithFrontMatter(fmData, content) + } + + private buildGlobRuleContent(child: ProjectChildrenMemoryPrompt): string { + const pattern = this.buildChildRulePattern(child) + const fmData: Record = { + apply: RULE_APPLY_GLOB, + [RULE_GLOB_KEY]: pattern + } + + return buildMarkdownWithFrontMatter(fmData, child.content as string) + } + + private async writeProjectRuleFile( + ctx: OutputWriteContext, + project: Project, + fileName: string, + content: string, + label: string + ): Promise { + const projectDir = project.dirFromWorkspacePath! + const rulesDir = path.join(projectDir.basePath, projectDir.path, AIASSISTANT_DIR, RULES_SUBDIR) + const fullPath = path.join(rulesDir, fileName) + + const relativePath = this.createProjectRuleFileRelativePath(projectDir, fileName) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: label, path: fullPath}) + return {path: relativePath, success: true, skipped: false} + } + + try { + this.ensureDirectory(rulesDir) + fs.writeFileSync(fullPath, content, 'utf8') + this.log.trace({action: 'write', type: label, path: fullPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: label, path: fullPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private isSupportedIdeDir(dirName: string): boolean { + return IDE_DIR_PREFIXES.some(prefix => dirName.startsWith(prefix)) + } + + private async writeGlobalFastCommand( + ctx: OutputWriteContext, + codexDir: string, + cmd: FastCommandPrompt + ): Promise { + const results: WriteResult[] = [] + const transformOptions = this.getTransformOptionsFromContext(ctx) + const fileName = this.transformFastCommandName(cmd, transformOptions) + const targetDir = path.join(codexDir, PROMPTS_SUBDIR) + const fullPath = path.join(targetDir, fileName) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.join(PROMPTS_SUBDIR, fileName), + basePath: codexDir, + getDirectoryName: () => PROMPTS_SUBDIR, + getAbsolutePath: () => fullPath + } + + const content = this.buildMarkdownContentWithRaw( + cmd.content, + cmd.yamlFrontMatter, + cmd.rawFrontMatter + ) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'globalFastCommand', path: fullPath}) + return [{path: relativePath, success: true, skipped: false}] + } + + try { + this.ensureDirectory(targetDir) + fs.writeFileSync(fullPath, content, 'utf8') + this.log.trace({action: 'write', type: 'globalFastCommand', path: fullPath}) + results.push({path: relativePath, success: true}) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'globalFastCommand', path: fullPath, error: errMsg}) + results.push({path: relativePath, success: false, error: error as Error}) + } + + return results + } + + private async writeGlobalSkill( + ctx: OutputWriteContext, + codexDir: string, + skill: SkillPrompt + ): Promise { + const results: WriteResult[] = [] + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const targetDir = path.join(codexDir, SKILLS_SUBDIR, skillName) + const fullPath = path.join(targetDir, SKILL_FILE_NAME) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.join(SKILLS_SUBDIR, skillName, SKILL_FILE_NAME), + basePath: codexDir, + getDirectoryName: () => skillName, + getAbsolutePath: () => fullPath + } + + const content = this.buildCodexSkillContent(skill) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'globalSkill', path: fullPath}) + return [{path: relativePath, success: true, skipped: false}] + } + + try { + this.ensureDirectory(targetDir) + fs.writeFileSync(fullPath, content, 'utf8') + this.log.trace({action: 'write', type: 'globalSkill', path: fullPath}) + results.push({path: relativePath, success: true}) + + if (skill.childDocs != null) { + for (const refDoc of skill.childDocs) { + const refResults = await this.writeSkillReferenceDocument(ctx, targetDir, skillName, refDoc, codexDir) + results.push(...refResults) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + const resourceResults = await this.writeSkillResource(ctx, targetDir, skillName, resource, codexDir) + results.push(...resourceResults) + } + } + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'globalSkill', path: fullPath, error: errMsg}) + results.push({path: relativePath, success: false, error: error as Error}) + } + + return results + } + + private buildCodexSkillContent(skill: SkillPrompt): string { + const fm = skill.yamlFrontMatter + + const name = this.normalizeSkillName(fm.name, 64) + const description = this.normalizeToSingleLine(fm.description, 1024) + + const metadata: Record = {} + + if (fm.displayName != null) metadata['short-description'] = fm.displayName + if (fm.version != null) metadata['version'] = fm.version + if (fm.author != null) metadata['author'] = fm.author + if (fm.keywords != null && fm.keywords.length > 0) metadata['keywords'] = [...fm.keywords] + + const fmData: Record = { + name, + description + } + + if (Object.keys(metadata).length > 0) fmData['metadata'] = metadata + if (fm.allowTools != null && fm.allowTools.length > 0) fmData['allowed-tools'] = fm.allowTools.join(' ') + + return buildMarkdownWithFrontMatter(fmData, skill.content as string) + } + + private normalizeSkillName(name: string, maxLength: number): string { + let normalized = name + .toLowerCase() + .replaceAll(/[^a-z0-9-]/g, '-') + .replaceAll(/-+/g, '-') + .replaceAll(/^-+|-+$/g, '') + + if (normalized.length > maxLength) normalized = normalized.slice(0, maxLength).replace(/-+$/, '') + + return normalized + } + + private normalizeToSingleLine(text: string, maxLength: number): string { + const singleLine = text.replaceAll(/[\r\n]+/g, ' ').replaceAll(/\s+/g, ' ').trim() + if (singleLine.length > maxLength) return `${singleLine.slice(0, maxLength - 3)}...` + return singleLine + } + + private async writeSkillReferenceDocument( + ctx: OutputWriteContext, + skillDir: string, + skillName: string, + refDoc: {dir: RelativePath, content: unknown}, + codexDir: string + ): Promise { + const results: WriteResult[] = [] + const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') + const fullPath = path.join(skillDir, fileName) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.join(SKILLS_SUBDIR, skillName, fileName), + basePath: codexDir, + getDirectoryName: () => skillName, + getAbsolutePath: () => fullPath + } + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'skillRefDoc', path: fullPath}) + return [{path: relativePath, success: true, skipped: false}] + } + + try { + const parentDir = path.dirname(fullPath) + this.ensureDirectory(parentDir) + fs.writeFileSync(fullPath, refDoc.content as string, 'utf8') + this.log.trace({action: 'write', type: 'skillRefDoc', path: fullPath}) + results.push({path: relativePath, success: true}) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'skillRefDoc', path: fullPath, error: errMsg}) + results.push({path: relativePath, success: false, error: error as Error}) + } + + return results + } + + private async writeSkillResource( + ctx: OutputWriteContext, + skillDir: string, + skillName: string, + resource: {relativePath: string, content: string}, + codexDir: string + ): Promise { + const results: WriteResult[] = [] + const fullPath = path.join(skillDir, resource.relativePath) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.join(SKILLS_SUBDIR, skillName, resource.relativePath), + basePath: codexDir, + getDirectoryName: () => skillName, + getAbsolutePath: () => fullPath + } + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'skillResource', path: fullPath}) + return [{path: relativePath, success: true, skipped: false}] + } + + try { + const parentDir = path.dirname(fullPath) + this.ensureDirectory(parentDir) + fs.writeFileSync(fullPath, resource.content, 'utf8') + this.log.trace({action: 'write', type: 'skillResource', path: fullPath}) + results.push({path: relativePath, success: true}) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'skillResource', path: fullPath, error: errMsg}) + results.push({path: relativePath, success: false, error: error as Error}) + } + + return results + } +} diff --git a/cli/src/plugins/plugin-jetbrains-ai-codex/index.ts b/cli/src/plugins/plugin-jetbrains-ai-codex/index.ts new file mode 100644 index 00000000..0a3c6461 --- /dev/null +++ b/cli/src/plugins/plugin-jetbrains-ai-codex/index.ts @@ -0,0 +1,3 @@ +export { + JetBrainsAIAssistantCodexOutputPlugin +} from './JetBrainsAIAssistantCodexOutputPlugin' diff --git a/cli/src/plugins/plugin-jetbrains-codestyle/JetBrainsIDECodeStyleConfigOutputPlugin.ts b/cli/src/plugins/plugin-jetbrains-codestyle/JetBrainsIDECodeStyleConfigOutputPlugin.ts new file mode 100644 index 00000000..1aa7e340 --- /dev/null +++ b/cli/src/plugins/plugin-jetbrains-codestyle/JetBrainsIDECodeStyleConfigOutputPlugin.ts @@ -0,0 +1,144 @@ +import type { + OutputPluginContext, + OutputWriteContext, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {FilePathKind, IDEKind} from '@truenine/plugin-shared' + +const IDEA_DIR = '.idea' +const CODE_STYLES_DIR = 'codeStyles' + +/** + * Default JetBrains IDE config files that this plugin manages. + * These are the relative paths within each project directory. + */ +const JETBRAINS_CONFIG_FILES = [ + '.editorconfig', + '.idea/codeStyles/Project.xml', + '.idea/codeStyles/codeStyleConfig.xml', + '.idea/.gitignore' +] as const + +export class JetBrainsIDECodeStyleConfigOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('JetBrainsIDECodeStyleConfigOutputPlugin') + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + const {jetbrainsConfigFiles, editorConfigFiles} = ctx.collectedInputContext + + const hasJetBrainsConfigs = (jetbrainsConfigFiles != null && jetbrainsConfigFiles.length > 0) + || (editorConfigFiles != null && editorConfigFiles.length > 0) + if (!hasJetBrainsConfigs) return results + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + + if (project.isPromptSourceProject === true) continue + + for (const configFile of JETBRAINS_CONFIG_FILES) { + const filePath = this.joinPath(projectDir.path, configFile) + results.push({ + pathKind: FilePathKind.Relative, + path: filePath, + basePath: projectDir.basePath, + getDirectoryName: () => this.dirname(configFile), + getAbsolutePath: () => this.resolvePath(projectDir.basePath, filePath) + }) + } + } + + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {jetbrainsConfigFiles, editorConfigFiles} = ctx.collectedInputContext + const hasIdeaConfigs = (jetbrainsConfigFiles != null && jetbrainsConfigFiles.length > 0) + || (editorConfigFiles != null && editorConfigFiles.length > 0) + + if (hasIdeaConfigs) return true + + this.log.debug('skipped', {reason: 'no JetBrains IDE config files found'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + const {jetbrainsConfigFiles, editorConfigFiles} = ctx.collectedInputContext + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + const jetbrainsConfigs = [ + ...jetbrainsConfigFiles ?? [], + ...editorConfigFiles ?? [] + ] + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + + const projectName = project.name ?? 'unknown' + + for (const config of jetbrainsConfigs) { + const result = await this.writeConfigFile(ctx, projectDir, config, `project:${projectName}`) + fileResults.push(result) + } + } + + return {files: fileResults, dirs: dirResults} + } + + private async writeConfigFile( + ctx: OutputWriteContext, + projectDir: RelativePath, + config: {type: IDEKind, content: string, dir: {path: string}}, + label: string + ): Promise { + const targetRelativePath = this.getTargetRelativePath(config) + const fullPath = this.resolvePath(projectDir.basePath, projectDir.path, targetRelativePath) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: this.joinPath(projectDir.path, targetRelativePath), + basePath: projectDir.basePath, + getDirectoryName: () => this.dirname(targetRelativePath), + getAbsolutePath: () => fullPath + } + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'config', path: fullPath, label}) + return {path: relativePath, success: true, skipped: false} + } + + try { + const dir = this.dirname(fullPath) + this.ensureDirectory(dir) + this.writeFileSync(fullPath, config.content) + this.log.trace({action: 'write', type: 'config', 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: 'config', path: fullPath, label, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private getTargetRelativePath(config: {type: IDEKind, dir: {path: string}}): string { + const sourcePath = config.dir.path + + if (config.type === IDEKind.EditorConfig) return '.editorconfig' + + if (config.type !== IDEKind.IntellijIDEA) return this.basename(sourcePath) + + const ideaIndex = sourcePath.indexOf(IDEA_DIR) + if (ideaIndex !== -1) return sourcePath.slice(Math.max(0, ideaIndex)) + return this.joinPath(IDEA_DIR, CODE_STYLES_DIR, this.basename(sourcePath)) + } +} diff --git a/cli/src/plugins/plugin-jetbrains-codestyle/index.ts b/cli/src/plugins/plugin-jetbrains-codestyle/index.ts new file mode 100644 index 00000000..768102b3 --- /dev/null +++ b/cli/src/plugins/plugin-jetbrains-codestyle/index.ts @@ -0,0 +1,3 @@ +export { + JetBrainsIDECodeStyleConfigOutputPlugin +} from './JetBrainsIDECodeStyleConfigOutputPlugin' diff --git a/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts b/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts new file mode 100644 index 00000000..99b4c210 --- /dev/null +++ b/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts @@ -0,0 +1,189 @@ +import type { + FastCommandPrompt, + OutputPluginContext, + OutputWriteContext, + SkillPrompt, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import * as path from 'node:path' +import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {PLUGIN_NAMES} from '@truenine/plugin-shared' + +const PROJECT_MEMORY_FILE = 'AGENTS.md' +const GLOBAL_CONFIG_DIR = '.codex' +const PROMPTS_SUBDIR = 'prompts' +const SKILLS_SUBDIR = 'skills' +const SKILL_FILE_NAME = 'SKILL.md' + +export class CodexCLIOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('CodexCLIOutputPlugin', { + globalConfigDir: GLOBAL_CONFIG_DIR, + outputFileName: PROJECT_MEMORY_FILE, + dependsOn: [PLUGIN_NAMES.AgentsOutput] + }) + } + + async registerProjectOutputDirs(): Promise { + return [] // Codex only supports global prompts and skills + } + + async registerProjectOutputFiles(): Promise { + return [] // AGENTS.md files are handled by AgentsOutputPlugin + } + + async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { + const globalDir = this.getGlobalConfigDir() + const results: RelativePath[] = [ + this.createRelativePath(PROMPTS_SUBDIR, globalDir, () => PROMPTS_SUBDIR) + ] + + const {skills} = ctx.collectedInputContext + if (skills == null || skills.length === 0) return results + + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + results.push(this.createRelativePath( + path.join(SKILLS_SUBDIR, skillName), + globalDir, + () => skillName + )) + } + return results + } + + async registerGlobalOutputFiles(): Promise { + const globalDir = this.getGlobalConfigDir() + return [ + this.createRelativePath(PROJECT_MEMORY_FILE, globalDir, () => GLOBAL_CONFIG_DIR) + ] + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {globalMemory, fastCommands, skills} = ctx.collectedInputContext + if (globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0) return true + this.log.trace({action: 'skip', reason: 'noOutputs'}) + return false + } + + async writeProjectOutputs(): Promise { + return {files: [], dirs: []} // Handled by AgentsOutputPlugin + } + + async writeGlobalOutputs(ctx: OutputWriteContext): Promise { + const {globalMemory, fastCommands, skills} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const fileResults: WriteResult[] = [] + const globalDir = this.getGlobalConfigDir() + + if (globalMemory != null) { + const fullPath = path.join(globalDir, PROJECT_MEMORY_FILE) + const result = await this.writeFile(ctx, fullPath, globalMemory.content as string, 'globalMemory') + fileResults.push(result) + } + + if (fastCommands != null && fastCommands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + for (const cmd of filteredCommands) { + const result = await this.writeGlobalFastCommand(ctx, globalDir, cmd) + fileResults.push(result) + } + } + + if (skills == null || skills.length === 0) return {files: fileResults, dirs: []} + + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) { + const skillResults = await this.writeGlobalSkill(ctx, globalDir, skill) + fileResults.push(...skillResults) + } + return {files: fileResults, dirs: []} + } + + private async writeGlobalFastCommand( + ctx: OutputWriteContext, + globalDir: string, + cmd: FastCommandPrompt + ): Promise { + const transformOptions = this.getTransformOptionsFromContext(ctx) + const fileName = this.transformFastCommandName(cmd, transformOptions) + const fullPath = path.join(globalDir, PROMPTS_SUBDIR, fileName) + const content = this.buildMarkdownContentWithRaw(cmd.content, cmd.yamlFrontMatter, cmd.rawFrontMatter) + return this.writeFile(ctx, fullPath, content, 'globalFastCommand') + } + + private async writeGlobalSkill( + ctx: OutputWriteContext, + globalDir: string, + skill: SkillPrompt + ): Promise { + const results: WriteResult[] = [] + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const skillDir = path.join(globalDir, SKILLS_SUBDIR, skillName) + const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) + + const content = this.buildCodexSkillContent(skill) + const mainResult = await this.writeFile(ctx, skillFilePath, content, 'globalSkill') + results.push(mainResult) + + if (skill.childDocs != null) { + for (const refDoc of skill.childDocs) { + const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') + const fullPath = path.join(skillDir, fileName) + const refResult = await this.writeFile(ctx, fullPath, refDoc.content as string, 'skillRefDoc') + results.push(refResult) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + const fullPath = path.join(skillDir, resource.relativePath) + const resourceResult = await this.writeFile(ctx, fullPath, resource.content, 'skillResource') + results.push(resourceResult) + } + } + + return results + } + + private buildCodexSkillContent(skill: SkillPrompt): string { + const fm = skill.yamlFrontMatter + const name = this.normalizeSkillName(fm.name, 64) + const description = this.normalizeToSingleLine(fm.description, 1024) + + const metadata: Record = {} + if (fm.displayName != null) metadata['short-description'] = fm.displayName + if (fm.version != null) metadata['version'] = fm.version + if (fm.author != null) metadata['author'] = fm.author + if (fm.keywords != null && fm.keywords.length > 0) metadata['keywords'] = [...fm.keywords] + + const fmData: Record = {name, description} + if (Object.keys(metadata).length > 0) fmData['metadata'] = metadata + if (fm.allowTools != null && fm.allowTools.length > 0) fmData['allowed-tools'] = fm.allowTools.join(' ') + + return buildMarkdownWithFrontMatter(fmData, skill.content as string) + } + + private normalizeSkillName(name: string, maxLength: number): string { + let normalized = name + .toLowerCase() + .replaceAll(/[^a-z0-9-]/g, '-') + .replaceAll(/-+/g, '-') + .replaceAll(/^-+|-+$/g, '') + + if (normalized.length > maxLength) normalized = normalized.slice(0, maxLength).replace(/-+$/, '') + return normalized + } + + private normalizeToSingleLine(text: string, maxLength: number): string { + const singleLine = text.replaceAll(/[\r\n]+/g, ' ').replaceAll(/\s+/g, ' ').trim() + if (singleLine.length > maxLength) return `${singleLine.slice(0, maxLength - 3)}...` + return singleLine + } +} diff --git a/cli/src/plugins/plugin-openai-codex-cli/index.ts b/cli/src/plugins/plugin-openai-codex-cli/index.ts new file mode 100644 index 00000000..f1affd58 --- /dev/null +++ b/cli/src/plugins/plugin-openai-codex-cli/index.ts @@ -0,0 +1,3 @@ +export { + CodexCLIOutputPlugin +} from './CodexCLIOutputPlugin' diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts new file mode 100644 index 00000000..135af5cf --- /dev/null +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts @@ -0,0 +1,231 @@ +import type {OutputPluginContext} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {OpencodeCLIOutputPlugin} from './OpencodeCLIOutputPlugin' + +class TestableOpencodeCLIOutputPlugin extends OpencodeCLIOutputPlugin { + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } +} + +function createMockContext( + tempDir: string, + rules: unknown[], + projects: unknown[] +): OutputPluginContext { + return { + collectedInputContext: { + workspace: { + projects: projects as never, + directory: { + pathKind: 1, + path: tempDir, + basePath: tempDir, + getDirectoryName: () => 'workspace', + getAbsolutePath: () => tempDir + } + }, + ideConfigFiles: [], + rules: rules as never, + fastCommands: [], + skills: [], + globalMemory: void 0, + aiAgentIgnoreConfigFiles: [], + subAgents: [] + }, + logger: { + debug: vi.fn(), + trace: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + } as never, + fs, + path, + glob: vi.fn() as never + } +} + +describe('opencodeCLIOutputPlugin - projectConfig filtering', () => { + let tempDir: string, + plugin: TestableOpencodeCLIOutputPlugin + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-proj-config-test-')) + plugin = new TestableOpencodeCLIOutputPlugin() + plugin.setMockHomeDir(tempDir) + }) + + afterEach(() => { + try { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch {} + }) + + describe('registerProjectOutputFiles', () => { + it('should include all project rules when no projectConfig', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [createMockProject('proj1', tempDir, 'proj1')] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.md') + expect(fileNames).toContain('rule-test-rule2.md') + }) + + it('should filter rules by include in projectConfig', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.md') + expect(fileNames).not.toContain('rule-test-rule2.md') + }) + + it('should filter rules by includeSeries excluding non-matching series', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).not.toContain('rule-test-rule1.md') + expect(fileNames).toContain('rule-test-rule2.md') + }) + + it('should include rules without seriName regardless of include filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', void 0, 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.md') + expect(fileNames).not.toContain('rule-test-rule2.md') + }) + + it('should filter independently for each project', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}), + createMockProject('proj2', tempDir, 'proj2', {rules: {includeSeries: ['vue']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = results.map(r => ({ + path: r.path, + fileName: r.path.split(/[/\\]/).pop() + })) + + expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule1.md')).toBe(true) + expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule2.md')).toBe(false) + expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule2.md')).toBe(true) + expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule1.md')).toBe(false) + }) + + it('should return empty when include matches nothing', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const ruleFiles = results.filter(r => r.path.includes('rule-')) + + expect(ruleFiles).toHaveLength(0) + }) + }) + + describe('registerProjectOutputDirs', () => { + it('should not register rules dir when all rules filtered out', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputDirs(ctx) + const rulesDirs = results.filter(r => r.path.includes('rules')) + + expect(rulesDirs).toHaveLength(0) + }) + + it('should register rules dir when rules match filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputDirs(ctx) + const rulesDirs = results.filter(r => r.path.includes('rules')) + + expect(rulesDirs.length).toBeGreaterThan(0) + }) + }) + + describe('project rules directory path', () => { + it('should use .opencode/rules/ for project rules', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [createMockProject('proj1', tempDir, 'proj1')] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const ruleFile = results.find(r => r.path.includes('rule-test-rule1.md')) + + expect(ruleFile).toBeDefined() + expect(ruleFile?.path).toContain('.opencode') + expect(ruleFile?.path).toContain('rules') + }) + }) +}) diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.property.test.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.property.test.ts new file mode 100644 index 00000000..0ca383b5 --- /dev/null +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.property.test.ts @@ -0,0 +1,158 @@ +import type {CollectedInputContext, OutputPluginContext, Project, RelativePath, RulePrompt} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' +import * as fc from 'fast-check' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {OpencodeCLIOutputPlugin} from './OpencodeCLIOutputPlugin' + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { + return {pathKind: FilePathKind.Relative, path: pathStr, basePath, getDirectoryName: () => pathStr, getAbsolutePath: () => path.join(basePath, pathStr)} +} + +class TestablePlugin extends OpencodeCLIOutputPlugin { + private mockHomeDir: string | null = null + public setMockHomeDir(dir: string | null): void { this.mockHomeDir = dir } + protected override getHomeDir(): string { return this.mockHomeDir ?? super.getHomeDir() } + public testBuildRuleFileName(rule: RulePrompt): string { return (this as any).buildRuleFileName(rule) } + public testBuildRuleContent(rule: RulePrompt): string { return (this as any).buildRuleContent(rule) } +} + +function createMockRulePrompt(opts: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { + const content = opts.content ?? '# Rule body' + return {type: PromptKind.Rule, content, length: content.length, filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', ''), markdownContents: [], yamlFrontMatter: {description: 'ignored', globs: opts.globs}, series: opts.series, ruleName: opts.ruleName, globs: opts.globs, scope: opts.scope ?? 'global'} as RulePrompt +} + +const seriesGen = fc.stringMatching(/^[a-z0-9]{1,5}$/) +const ruleNameGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,14}$/) +const globGen = fc.stringMatching(/^[a-z*/.]{1,30}$/).filter(s => s.length > 0) +const globsGen = fc.array(globGen, {minLength: 1, maxLength: 5}) +const contentGen = fc.string({minLength: 1, maxLength: 200}).filter(s => s.trim().length > 0) + +describe('opencodeCLIOutputPlugin property tests', () => { + let tempDir: string, plugin: TestablePlugin, mockContext: OutputPluginContext + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-prop-')) + plugin = new TestablePlugin() + plugin.setMockHomeDir(tempDir) + mockContext = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + globalMemory: {type: PromptKind.GlobalMemory, content: 'mem', filePathKind: FilePathKind.Absolute, dir: createMockRelativePath('.', tempDir), markdownContents: []}, + fastCommands: [], + subAgents: [], + skills: [] + } as unknown as CollectedInputContext, + logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, + fs, + path, + glob: {} as any + } + }, 30000) + + afterEach(() => { + try { fs.rmSync(tempDir, {recursive: true, force: true}) } + catch {} + }) + + describe('rule file name format', () => { + it('should always produce rule-{series}-{ruleName}.md', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, async (series, ruleName) => { + const rule = createMockRulePrompt({series, ruleName, globs: []}) + const fileName = plugin.testBuildRuleFileName(rule) + expect(fileName).toBe(`rule-${series}-${ruleName}.md`) + expect(fileName).toMatch(/^rule-.[^-\n\r\u2028\u2029]*-.+\.md$/) + }), {numRuns: 100}) + }) + }) + + describe('rule content format constraints', () => { + it('should never contain paths field in frontmatter', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, content}) + const output = plugin.testBuildRuleContent(rule) + expect(output).not.toMatch(/^paths:/m) + }), {numRuns: 100}) + }) + + it('should use globs field when globs are present', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, content}) + const output = plugin.testBuildRuleContent(rule) + expect(output).toContain('globs:') + }), {numRuns: 100}) + }) + + it('should wrap frontmatter in --- delimiters when globs exist', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, content}) + const output = plugin.testBuildRuleContent(rule) + const lines = output.split('\n') + expect(lines[0]).toBe('---') + expect(lines.indexOf('---', 1)).toBeGreaterThan(0) + }), {numRuns: 100}) + }) + + it('should have no frontmatter when globs are empty', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, contentGen, async (series, ruleName, content) => { + const rule = createMockRulePrompt({series, ruleName, globs: [], content}) + const output = plugin.testBuildRuleContent(rule) + expect(output).not.toContain('---') + expect(output).toBe(content) + }), {numRuns: 100}) + }) + + it('should preserve rule body content after frontmatter', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, content}) + const output = plugin.testBuildRuleContent(rule) + expect(output).toContain(content) + }), {numRuns: 100}) + }) + + it('should list each glob as a YAML array item under globs', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, content}) + const output = plugin.testBuildRuleContent(rule) + for (const g of globs) expect(output).toMatch(new RegExp(`- "${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}"|- ${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)) + }), {numRuns: 100}) + }) + }) + + describe('write output format verification', () => { + it('should write global rule files with correct format to ~/.config/opencode/rules/', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const rule = createMockRulePrompt({series, ruleName, globs, scope: 'global', content}) + const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, rules: [rule]}} as any + await plugin.writeGlobalOutputs(ctx) + const filePath = path.join(tempDir, '.config/opencode', 'rules', `rule-${series}-${ruleName}.md`) + expect(fs.existsSync(filePath)).toBe(true) + const written = fs.readFileSync(filePath, 'utf8') + expect(written).toContain('globs:') + expect(written).not.toMatch(/^paths:/m) + expect(written).toContain(content) + }), {numRuns: 30}) + }) + + it('should write project rule files to {project}/.opencode/rules/', async () => { + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { + const mockProject: Project = { + name: 'proj', + dirFromWorkspacePath: createMockRelativePath('proj', tempDir), + rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', tempDir) as any, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}}, + childMemoryPrompts: [] + } + const rule = createMockRulePrompt({series, ruleName, globs, scope: 'project', content}) + const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, rules: [rule]}} as any + await plugin.writeProjectOutputs(ctx) + const filePath = path.join(tempDir, 'proj', '.opencode', 'rules', `rule-${series}-${ruleName}.md`) + expect(fs.existsSync(filePath)).toBe(true) + const written = fs.readFileSync(filePath, 'utf8') + expect(written).toContain('globs:') + expect(written).toContain(content) + }), {numRuns: 30}) + }) + }) +}) diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts new file mode 100644 index 00000000..068f2dd0 --- /dev/null +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts @@ -0,0 +1,777 @@ +import type {CollectedInputContext, FastCommandPrompt, OutputPluginContext, Project, RelativePath, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {OpencodeCLIOutputPlugin} from './OpencodeCLIOutputPlugin' + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { + return { + pathKind: FilePathKind.Relative, + path: pathStr, + basePath, + getDirectoryName: () => pathStr, + getAbsolutePath: () => path.join(basePath, pathStr) + } +} + +class TestableOpencodeCLIOutputPlugin extends OpencodeCLIOutputPlugin { + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } + + public testBuildRuleFileName(rule: RulePrompt): string { + return (this as any).buildRuleFileName(rule) + } + + public testBuildRuleContent(rule: RulePrompt): string { + return (this as any).buildRuleContent(rule) + } +} + +function createMockRulePrompt(options: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { + const content = options.content ?? '# Rule body' + return { + type: PromptKind.Rule, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', ''), + markdownContents: [], + yamlFrontMatter: {description: 'ignored', globs: options.globs}, + series: options.series, + ruleName: options.ruleName, + globs: options.globs, + scope: options.scope ?? 'global' + } as RulePrompt +} + +describe('opencodeCLIOutputPlugin', () => { + let tempDir: string, + plugin: TestableOpencodeCLIOutputPlugin, + mockContext: OutputPluginContext + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-test-')) + plugin = new TestableOpencodeCLIOutputPlugin() + plugin.setMockHomeDir(tempDir) + + mockContext = { + collectedInputContext: { + workspace: { + projects: [], + directory: createMockRelativePath('.', tempDir) + }, + globalMemory: { + type: PromptKind.GlobalMemory, + content: 'Global Memory Content', + filePathKind: FilePathKind.Absolute, + dir: createMockRelativePath('.', tempDir), + markdownContents: [] + }, + fastCommands: [], + subAgents: [], + skills: [] + } as unknown as CollectedInputContext, + logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, + fs, + path, + glob: {} as any + } + }) + + afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch { + } // ignore cleanup errors + } + }) + + describe('constructor', () => { + it('should have correct plugin name', () => expect(plugin.name).toBe('OpencodeCLIOutputPlugin')) + + it('should have correct dependencies', () => expect(plugin.dependsOn).toContain('AgentsOutputPlugin')) + }) + + describe('registerGlobalOutputDirs', () => { + it('should register commands, agents, and skills subdirectories in .config/opencode', async () => { + const dirs = await plugin.registerGlobalOutputDirs(mockContext) + + const dirPaths = dirs.map(d => d.path) + expect(dirPaths).toContain('commands') + expect(dirPaths).toContain('agents') + expect(dirPaths).toContain('skills') + + const expectedBasePath = path.join(tempDir, '.config/opencode') + dirs.forEach(d => expect(d.basePath).toBe(expectedBasePath)) + }) + }) + + describe('registerProjectOutputDirs', () => { + it('should register project cleanup directories', async () => { + const mockProject: Project = { + name: 'test-project', + dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), + rootMemoryPrompt: { + type: PromptKind.ProjectRootMemory, + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', tempDir) as any, + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} + }, + childMemoryPrompts: [] + } + + const ctxWithProject = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + workspace: { + ...mockContext.collectedInputContext.workspace, + projects: [mockProject] + } + } + } + + const dirs = await plugin.registerProjectOutputDirs(ctxWithProject) + const dirPaths = dirs.map(d => d.path) + + expect(dirPaths.some(p => p.includes(path.join('.config/opencode', 'commands')))).toBe(true) + expect(dirPaths.some(p => p.includes(path.join('.config/opencode', 'agents')))).toBe(true) + expect(dirPaths.some(p => p.includes(path.join('.config/opencode', 'skills')))).toBe(true) + }) + }) + + describe('registerGlobalOutputFiles', () => { + it('should register AGENTS.md in global config dir', async () => { + const files = await plugin.registerGlobalOutputFiles(mockContext) + const outputFile = files.find(f => f.path === 'AGENTS.md') + + expect(outputFile).toBeDefined() + expect(outputFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) + }) + + it('should register fast commands in commands subdirectory', async () => { + const mockCmd: FastCommandPrompt = { + type: PromptKind.FastCommand, + commandName: 'test-cmd', + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('test-cmd', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, description: 'desc'} + } + + const ctxWithCmd = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + fastCommands: [mockCmd] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithCmd) + const cmdFile = files.find(f => f.path.includes('test-cmd.md')) + + expect(cmdFile).toBeDefined() + expect(cmdFile?.path).toContain('commands') + expect(cmdFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) + }) + + it('should register agents in agents subdirectory', async () => { + const mockAgent: SubAgentPrompt = { + type: PromptKind.SubAgent, + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('review-agent.md', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'review-agent', description: 'Code review agent'} + } + + const ctxWithAgent = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + subAgents: [mockAgent] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) + const agentFile = files.find(f => f.path.includes('review-agent.md')) + + expect(agentFile).toBeDefined() + expect(agentFile?.path).toContain('agents') + expect(agentFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) + }) + + it('should strip .mdx suffix from agent path and use .md', async () => { + const mockAgent: SubAgentPrompt = { + type: PromptKind.SubAgent, + content: 'agent content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('code-review.cn.mdx', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'Code review agent'} + } + + const ctxWithAgent = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + subAgents: [mockAgent] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) + const agentFile = files.find(f => f.path.includes('agents')) + + expect(agentFile).toBeDefined() + expect(agentFile?.path).toContain('code-review.cn.md') + expect(agentFile?.path).not.toContain('.mdx') + }) + + it('should register skills in skills subdirectory', 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'} + } + + const ctxWithSkill = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + skills: [mockSkill] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) + const skillFile = files.find(f => f.path.includes('SKILL.md')) + + expect(skillFile).toBeDefined() + expect(skillFile?.path).toContain('skills') + expect(skillFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) + }) + }) + + describe('registerProjectOutputFiles', () => { + it('should return empty array (no project-level AGENTS.md)', async () => { + const mockProject: Project = { + name: 'test-project', + dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), + childMemoryPrompts: [] + } + + const ctxWithProject = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + workspace: { + ...mockContext.collectedInputContext.workspace, + projects: [mockProject] + } + } + } + + const files = await plugin.registerProjectOutputFiles(ctxWithProject) + expect(files).toEqual([]) + }) + }) + + describe('skill name normalization', () => { + it('should normalize skill names to opencode format', async () => { + const testCases = [ + {input: 'My Skill', expected: 'my-skill'}, + {input: 'Skill__Name', expected: 'skill-name'}, + {input: '-skill-', expected: 'skill'}, + {input: 'UPPER_CASE', expected: 'upper-case'}, + {input: 'tool.name', expected: 'tool-name'}, + {input: 'a'.repeat(70), expected: 'a'.repeat(64)} // truncated to 64 chars + ] + + for (const {input, expected} of testCases) { + const mockSkill: SkillPrompt = { + type: PromptKind.Skill, + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath(input, tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: input, description: 'desc'} + } + + const ctxWithSkill = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + skills: [mockSkill] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) + const skillFile = files.find(f => f.path.includes('SKILL.md')) + + expect(skillFile).toBeDefined() + expect(skillFile?.path).toContain(`skills/${expected}/`) + } + }) + }) + + describe('mcp config output', () => { + it('should register opencode.json when skill has 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: { + 'test-server': {command: 'test-cmd'} + } + } + } + + const ctxWithSkill = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + skills: [mockSkill] + } + } + + const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) + const configFile = files.find(f => f.path === 'opencode.json') + + expect(configFile).toBeDefined() + expect(configFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) + }) + + it('should write correct local 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', + args: ['index.js'], + env: {KEY: 'value'} + } + } + } + } + + const ctxWithSkill = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + skills: [mockSkill] + } + } + + await plugin.writeGlobalOutputs(ctxWithSkill) + + const configPath = path.join(tempDir, '.config/opencode/opencode.json') + expect(fs.existsSync(configPath)).toBe(true) + + const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) + expect(content.mcp).toBeDefined() + expect(content.mcp['local-server']).toBeDefined() + expect(content.mcp['local-server'].type).toBe('local') + expect(content.mcp['local-server'].command).toEqual(['node', 'index.js']) + expect(content.mcp['local-server'].environment).toEqual({KEY: 'value'}) + expect(content.mcp['local-server'].enabled).toBe(true) + }) + + it('should write correct remote 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: { + 'remote-server': { + url: 'https://example.com/mcp' + } as any + } + } + } + + 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')) + + expect(content.mcp['remote-server']).toBeDefined() + expect(content.mcp['remote-server'].type).toBe('remote') + 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', () => { + it('should write sub agent file with .md extension when source has .mdx', async () => { + const mockAgent: SubAgentPrompt = { + type: PromptKind.SubAgent, + content: '# Code Review Agent', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('reviewer.cn.mdx', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'reviewer', description: 'Code review agent'} + } + + const writeCtx = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + subAgents: [mockAgent] + } + } + + const results = await plugin.writeGlobalOutputs(writeCtx) + const agentResult = results.files.find(f => f.path.path === 'reviewer.cn.md') + + expect(agentResult).toBeDefined() + expect(agentResult?.success).toBe(true) + + const writtenPath = path.join(tempDir, '.config/opencode', 'agents', 'reviewer.cn.md') + expect(fs.existsSync(writtenPath)).toBe(true) + expect(fs.existsSync(path.join(tempDir, '.config/opencode', 'agents', 'reviewer.cn.mdx'))).toBe(false) + expect(fs.existsSync(path.join(tempDir, '.config/opencode', 'agents', 'reviewer.cn.mdx.md'))).toBe(false) + }) + }) + + describe('buildRuleFileName', () => { + it('should produce rule-{series}-{ruleName}.md', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'naming', globs: []}) + expect(plugin.testBuildRuleFileName(rule)).toBe('rule-01-naming.md') + }) + }) + + describe('buildRuleContent', () => { + it('should return plain content when globs is empty', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: [], content: '# No globs'}) + expect(plugin.testBuildRuleContent(rule)).toBe('# No globs') + }) + + it('should use globs field (not paths) in YAML frontmatter per opencode-rules format', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], content: '# TS rule'}) + const content = plugin.testBuildRuleContent(rule) + expect(content).toContain('globs:') + expect(content).not.toMatch(/^paths:/m) + }) + + it('should output globs as YAML array items', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts', '**/*.tsx'], content: '# Body'}) + const content = plugin.testBuildRuleContent(rule) + expect(content).toContain('- "**/*.ts"') + expect(content).toContain('- "**/*.tsx"') + }) + + it('should preserve rule body after frontmatter', () => { + const body = '# My Rule\n\nSome content.' + const rule = createMockRulePrompt({series: '01', ruleName: 'x', globs: ['*.ts'], content: body}) + const content = plugin.testBuildRuleContent(rule) + expect(content).toContain(body) + }) + + it('should wrap content in valid YAML frontmatter delimiters', () => { + const rule = createMockRulePrompt({series: '01', ruleName: 'x', globs: ['*.ts'], content: '# Body'}) + const content = plugin.testBuildRuleContent(rule) + const lines = content.split('\n') + expect(lines[0]).toBe('---') + expect(lines.indexOf('---', 1)).toBeGreaterThan(0) + }) + }) + + describe('rules registration', () => { + it('should register rules subdir in global output dirs when global rules exist', async () => { + const ctx = { + ...mockContext, + collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} + } + const dirs = await plugin.registerGlobalOutputDirs(ctx) + expect(dirs.map(d => d.path)).toContain('rules') + }) + + it('should not register rules subdir when no global rules', async () => { + const dirs = await plugin.registerGlobalOutputDirs(mockContext) + expect(dirs.map(d => d.path)).not.toContain('rules') + }) + + it('should register global rule files in ~/.config/opencode/rules/', async () => { + const ctx = { + ...mockContext, + collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} + } + const files = await plugin.registerGlobalOutputFiles(ctx) + const ruleFile = files.find(f => f.path === 'rule-01-ts.md') + expect(ruleFile).toBeDefined() + expect(ruleFile?.basePath).toBe(path.join(tempDir, '.config/opencode', 'rules')) + }) + + it('should not register project rules as global files', async () => { + const ctx = { + ...mockContext, + collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'project'})]} + } + const files = await plugin.registerGlobalOutputFiles(ctx) + expect(files.find(f => f.path.includes('rule-'))).toBeUndefined() + }) + }) + + describe('canWrite with rules', () => { + it('should return true when rules exist even without other content', async () => { + const ctx = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + globalMemory: void 0, + rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: []})] + } + } + expect(await plugin.canWrite(ctx as any)).toBe(true) + }) + }) + + describe('writeGlobalOutputs with rules', () => { + it('should write global rule file to ~/.config/opencode/rules/', async () => { + const ctx = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global', content: '# TS rule'})] + } + } + const results = await plugin.writeGlobalOutputs(ctx as any) + const ruleResult = results.files.find(f => f.path.path === 'rule-01-ts.md') + expect(ruleResult?.success).toBe(true) + + const filePath = path.join(tempDir, '.config/opencode', 'rules', 'rule-01-ts.md') + expect(fs.existsSync(filePath)).toBe(true) + const content = fs.readFileSync(filePath, 'utf8') + expect(content).toContain('globs:') + expect(content).toContain('# TS rule') + }) + + it('should write rule without frontmatter when globs is empty', async () => { + const ctx = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + rules: [createMockRulePrompt({series: '01', ruleName: 'general', globs: [], scope: 'global', content: '# Always apply'})] + } + } + await plugin.writeGlobalOutputs(ctx as any) + const filePath = path.join(tempDir, '.config/opencode', 'rules', 'rule-01-general.md') + const content = fs.readFileSync(filePath, 'utf8') + expect(content).toBe('# Always apply') + expect(content).not.toContain('---') + }) + }) + + describe('writeProjectOutputs with rules', () => { + it('should write project rule file to {project}/.opencode/rules/', async () => { + const mockProject: Project = { + name: 'proj', + dirFromWorkspacePath: createMockRelativePath('proj', tempDir), + rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', tempDir) as any, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}}, + childMemoryPrompts: [] + } + const ctx = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, + rules: [createMockRulePrompt({series: '02', ruleName: 'api', globs: ['src/api/**'], scope: 'project', content: '# API rules'})] + } + } + const results = await plugin.writeProjectOutputs(ctx as any) + expect(results.files.some(f => f.path.path === 'rule-02-api.md' && f.success)).toBe(true) + + const filePath = path.join(tempDir, 'proj', '.opencode', 'rules', 'rule-02-api.md') + expect(fs.existsSync(filePath)).toBe(true) + const content = fs.readFileSync(filePath, 'utf8') + expect(content).toContain('globs:') + expect(content).toContain('# API rules') + }) + }) +}) diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts new file mode 100644 index 00000000..ca9fac85 --- /dev/null +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts @@ -0,0 +1,467 @@ +import type {FastCommandPrompt, McpServerConfig, OutputPluginContext, OutputWriteContext, RulePrompt, SkillPrompt, SubAgentPrompt, WriteResult, WriteResults} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import * as fs from 'node:fs' +import * as path from 'node:path' +import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' +import {applySubSeriesGlobPrefix, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' + +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' +const PROJECT_RULES_DIR = '.opencode' +const RULES_SUBDIR = 'rules' +const RULE_FILE_PREFIX = 'rule-' + +/** + * Opencode CLI output plugin. + * Outputs global memory, commands, agents, and skills to ~/.config/opencode/ + */ +export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { + constructor() { + super('OpencodeCLIOutputPlugin', { + globalConfigDir: GLOBAL_CONFIG_DIR, + outputFileName: GLOBAL_MEMORY_FILE, + commandsSubDir: 'commands', + agentsSubDir: 'agents', + skillsSubDir: 'skills', + supportsFastCommands: true, + supportsSubAgents: true, + supportsSkills: true, + dependsOn: [PLUGIN_NAMES.AgentsOutput] + }) + + this.registerCleanEffect('mcp-config-cleanup', async ctx => { + const globalDir = this.getGlobalConfigDir() + const configPath = path.join(globalDir, OPENCODE_CONFIG_FILE) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'mcpConfigCleanup', path: configPath}) + return {success: true, description: 'Would reset opencode.json mcp to empty'} + } + + try { + if (fs.existsSync(configPath)) { + 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}) + return {success: true, description: 'Reset opencode.json mcp to empty'} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'clean', type: 'mcpConfigCleanup', path: configPath, error: errMsg}) + return {success: false, error: error as Error, description: 'Failed to reset opencode.json mcp'} + } + }) + } + + override async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { + const results = await super.registerGlobalOutputFiles(ctx) + const globalDir = this.getGlobalConfigDir() + + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const filteredSkills = ctx.collectedInputContext.skills != null + ? filterSkillsByProjectConfig(ctx.collectedInputContext.skills, projectConfig) + : [] + const hasAnyMcpConfig = filteredSkills.some(s => s.mcpConfig != null) + if (hasAnyMcpConfig) { + const configPath = path.join(globalDir, OPENCODE_CONFIG_FILE) + results.push({ + pathKind: FilePathKind.Relative, + path: OPENCODE_CONFIG_FILE, + basePath: globalDir, + getDirectoryName: () => GLOBAL_CONFIG_DIR, + getAbsolutePath: () => configPath + }) + } + + const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') + if (globalRules != null && globalRules.length > 0) { + const rulesDir = path.join(globalDir, RULES_SUBDIR) + for (const rule of globalRules) results.push(this.createRelativePath(this.buildRuleFileName(rule), rulesDir, () => RULES_SUBDIR)) + } + + return results.map(result => { // Normalize skill directory names in paths + const normalizedPath = result.path.replaceAll('\\', '/') + const skillsPatternWithSlash = `/${this.skillsSubDir}/` + const skillsPatternStart = `${this.skillsSubDir}/` + + if (!(normalizedPath.includes(skillsPatternWithSlash) || normalizedPath.startsWith(skillsPatternStart))) return result + + const pathParts = normalizedPath.split('/') + const skillsIndex = pathParts.indexOf(this.skillsSubDir) + if (skillsIndex < 0 || skillsIndex + 1 >= pathParts.length) return result + + const skillName = pathParts[skillsIndex + 1] + if (skillName == null) return result + + const normalizedSkillName = this.validateAndNormalizeSkillName(skillName) + const newPathParts = [...pathParts] + newPathParts[skillsIndex + 1] = normalizedSkillName + const newPath = newPathParts.join('/') + return { + ...result, + path: newPath, + getDirectoryName: () => normalizedSkillName, + getAbsolutePath: () => path.join(globalDir, newPath.replaceAll('/', path.sep)) + } + }) + } + + override async writeGlobalOutputs(ctx: OutputWriteContext): Promise { + const baseResults = await super.writeGlobalOutputs(ctx) + const files = [...baseResults.files] + + const {skills} = ctx.collectedInputContext + if (skills != null) { + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + const mcpResult = await this.writeGlobalMcpConfig(ctx, filteredSkills) + if (mcpResult != null) files.push(mcpResult) + } + + const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') + if (globalRules == null || globalRules.length === 0) return {files, dirs: baseResults.dirs} + + const rulesDir = path.join(this.getGlobalConfigDir(), RULES_SUBDIR) + for (const rule of globalRules) files.push(await this.writeFile(ctx, path.join(rulesDir, this.buildRuleFileName(rule)), this.buildRuleContent(rule), 'rule')) + return {files, dirs: baseResults.dirs} + } + + private async writeGlobalMcpConfig( + ctx: OutputWriteContext, + skills: readonly SkillPrompt[] + ): Promise { + const mergedMcpServers: Record = {} + + for (const skill of skills) { + if (skill.mcpConfig == null) continue + const {mcpServers} = skill.mcpConfig + for (const [mcpName, mcpConfig] of Object.entries(mcpServers)) mergedMcpServers[mcpName] = this.transformMcpConfigForOpencode(mcpConfig) + } + + if (Object.keys(mergedMcpServers).length === 0) return null + + const globalDir = this.getGlobalConfigDir() + const configPath = path.join(globalDir, OPENCODE_CONFIG_FILE) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: OPENCODE_CONFIG_FILE, + basePath: globalDir, + getDirectoryName: () => GLOBAL_CONFIG_DIR, + getAbsolutePath: () => configPath + } + + let existingConfig: Record = {} + try { + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf8') + existingConfig = JSON.parse(content) as Record + } + } + catch { + existingConfig = {} + } + + 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) { + this.log.trace({action: 'dryRun', type: 'globalMcpConfig', path: configPath, serverCount: Object.keys(mergedMcpServers).length}) + return {path: relativePath, success: true, skipped: false} + } + + try { + this.ensureDirectory(globalDir) + fs.writeFileSync(configPath, content) + this.log.trace({action: 'write', type: 'globalMcpConfig', path: configPath, serverCount: Object.keys(mergedMcpServers).length}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'globalMcpConfig', path: configPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private transformMcpConfigForOpencode(config: McpServerConfig): Record { + const result: Record = {} + + if (config.command != null) { + result['type'] = 'local' + const commandArray = [config.command] + if (config.args != null) commandArray.push(...config.args) + result['command'] = commandArray + if (config.env != null) result['environment'] = config.env + } else { + result['type'] = 'remote' + const configRecord = config as unknown as Record + if (configRecord['url'] != null) result['url'] = configRecord['url'] + else if (configRecord['serverUrl'] != null) result['url'] = configRecord['serverUrl'] + } + + result['enabled'] = config.disabled !== true + + return result + } + + protected override async writeSubAgent( + ctx: OutputWriteContext, + basePath: string, + agent: SubAgentPrompt + ): Promise { + const fileName = agent.dir.path.replace(/\.mdx$/, '.md') + const targetDir = path.join(basePath, this.agentsSubDir) + const fullPath = path.join(targetDir, fileName) + + const opencodeFrontMatter = this.buildOpencodeAgentFrontMatter(agent) + const content = this.buildMarkdownContent(agent.content, opencodeFrontMatter) + + return [await this.writeFile(ctx, fullPath, content, 'subAgent')] + } + + private buildOpencodeAgentFrontMatter(agent: SubAgentPrompt): Record { + const frontMatter: Record = {} + const source = agent.yamlFrontMatter as Record | undefined + + if (source?.['description'] != null) frontMatter['description'] = source['description'] + + frontMatter['mode'] = source?.['mode'] ?? 'subagent' + + if (source?.['model'] != null) frontMatter['model'] = source['model'] + if (source?.['temperature'] != null) frontMatter['temperature'] = source['temperature'] + if (source?.['maxSteps'] != null) frontMatter['maxSteps'] = source['maxSteps'] + if (source?.['hidden'] != null) frontMatter['hidden'] = source['hidden'] + + if (source?.['allowTools'] != null && Array.isArray(source['allowTools'])) { + const tools: Record = {} + for (const tool of source['allowTools']) tools[String(tool)] = true + frontMatter['tools'] = tools + } + + if (source?.['permission'] != null && typeof source['permission'] === 'object') frontMatter['permission'] = source['permission'] + + for (const [key, value] of Object.entries(source ?? {})) { + if (!['description', 'mode', 'model', 'temperature', 'maxSteps', 'hidden', 'allowTools', 'permission', 'namingCase', 'name', 'color'].includes(key)) { + frontMatter[key] = value + } + } + + return frontMatter + } + + protected override async writeFastCommand( + ctx: OutputWriteContext, + basePath: string, + cmd: FastCommandPrompt + ): Promise { + const transformOptions = this.getTransformOptionsFromContext(ctx) + const fileName = this.transformFastCommandName(cmd, transformOptions) + const targetDir = path.join(basePath, this.commandsSubDir) + const fullPath = path.join(targetDir, fileName) + + const opencodeFrontMatter = this.buildOpencodeCommandFrontMatter(cmd) + const content = this.buildMarkdownContent(cmd.content, opencodeFrontMatter) + + return [await this.writeFile(ctx, fullPath, content, 'fastCommand')] + } + + private buildOpencodeCommandFrontMatter(cmd: FastCommandPrompt): Record { + const frontMatter: Record = {} + const source = cmd.yamlFrontMatter as Record | undefined + + if (source?.['description'] != null) frontMatter['description'] = source['description'] + if (source?.['agent'] != null) frontMatter['agent'] = source['agent'] + if (source?.['model'] != null) frontMatter['model'] = source['model'] + + if (source?.['allowTools'] != null && Array.isArray(source['allowTools'])) { + const tools: Record = {} + for (const tool of source['allowTools']) tools[String(tool)] = true + frontMatter['tools'] = tools + } + + for (const [key, value] of Object.entries(source ?? {})) { + if (!['description', 'agent', 'model', 'allowTools', 'namingCase', 'argumentHint'].includes(key)) frontMatter[key] = value + } + + return frontMatter + } + + protected override async writeSkill( + ctx: OutputWriteContext, + basePath: string, + skill: SkillPrompt + ): Promise { + const results: WriteResult[] = [] + const skillName = this.validateAndNormalizeSkillName((skill.yamlFrontMatter?.name as string | undefined) ?? skill.dir.getDirectoryName()) + const targetDir = path.join(basePath, this.skillsSubDir, skillName) + const fullPath = path.join(targetDir, 'SKILL.md') + + const opencodeFrontMatter = this.buildOpencodeSkillFrontMatter(skill, skillName) + const content = this.buildMarkdownContent(skill.content as string, opencodeFrontMatter) + + const mainFileResult = await this.writeFile(ctx, fullPath, content, 'skill') + results.push(mainFileResult) + + if (skill.childDocs != null) { + for (const refDoc of skill.childDocs) { + const refResults = await this.writeSkillReferenceDocument(ctx, targetDir, skillName, refDoc, basePath) + results.push(...refResults) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + const refResults = await this.writeSkillResource(ctx, targetDir, skillName, resource, basePath) + results.push(...refResults) + } + } + + return results + } + + private buildOpencodeSkillFrontMatter(skill: SkillPrompt, skillName: string): Record { + const frontMatter: Record = {} + const source = skill.yamlFrontMatter as Record | undefined + + frontMatter['name'] = skillName + if (source?.['description'] != null) frontMatter['description'] = source['description'] + + frontMatter['license'] = source?.['license'] ?? 'MIT' + frontMatter['compatibility'] = source?.['compatibility'] ?? 'opencode' + + const metadata: Record = {} + const metadataFields = ['author', 'version', 'keywords', 'category', 'repository', 'displayName'] + + for (const field of metadataFields) { + if (source?.[field] != null) metadata[field] = source[field] + } + + const reservedFields = new Set(['name', 'description', 'license', 'compatibility', 'namingCase', 'allowTools', 'keywords', 'displayName', 'author', 'version']) + for (const [key, value] of Object.entries(source ?? {})) { + if (!reservedFields.has(key)) metadata[key] = value + } + + if (Object.keys(metadata).length > 0) frontMatter['metadata'] = metadata + + return frontMatter + } + + private validateAndNormalizeSkillName(name: string): string { + let normalized = name.toLowerCase() + normalized = normalized.replaceAll(/[^a-z0-9-]+/g, '-') + normalized = normalized.replaceAll(/-+/g, '-') + normalized = normalized.replaceAll(/^-|-$/g, '') + + if (normalized.length === 0) normalized = 'skill' + else if (normalized.length > 64) { + normalized = normalized.slice(0, 64) + normalized = normalized.replace(/-$/, '') + } + + return normalized + } + + private buildRuleFileName(rule: RulePrompt): string { + return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` + } + + private buildRuleContent(rule: RulePrompt): string { + if (rule.globs.length === 0) return rule.content + return this.buildMarkdownContent(rule.content, {globs: [...rule.globs]}) + } + + override async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { + const results = await super.registerGlobalOutputDirs(ctx) + const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') + if (globalRules != null && globalRules.length > 0) results.push(this.createRelativePath(RULES_SUBDIR, this.getGlobalConfigDir(), () => RULES_SUBDIR)) + return results + } + + override async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const results = await super.registerProjectOutputDirs(ctx) + const {rules} = ctx.collectedInputContext + if (rules == null || rules.length === 0) return results + for (const project of ctx.collectedInputContext.workspace.projects) { + if (project.dirFromWorkspacePath == null) continue + const projectRules = applySubSeriesGlobPrefix( + filterRulesByProjectConfig( + rules.filter(r => this.normalizeRuleScope(r) === 'project'), + project.projectConfig + ), + project.projectConfig + ) + if (projectRules.length === 0) continue + const dirPath = path.join(project.dirFromWorkspacePath.path, PROJECT_RULES_DIR, RULES_SUBDIR) + results.push(this.createRelativePath(dirPath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) + } + return results + } + + override async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results = await super.registerProjectOutputFiles(ctx) + const {rules} = ctx.collectedInputContext + if (rules == null || rules.length === 0) return results + for (const project of ctx.collectedInputContext.workspace.projects) { + if (project.dirFromWorkspacePath == null) continue + const projectRules = applySubSeriesGlobPrefix( + filterRulesByProjectConfig( + rules.filter(r => this.normalizeRuleScope(r) === 'project'), + project.projectConfig + ), + project.projectConfig + ) + for (const rule of projectRules) { + const filePath = path.join(project.dirFromWorkspacePath.path, PROJECT_RULES_DIR, RULES_SUBDIR, this.buildRuleFileName(rule)) + results.push(this.createRelativePath(filePath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) + } + } + return results + } + + override async canWrite(ctx: OutputWriteContext): Promise { + if ((ctx.collectedInputContext.rules?.length ?? 0) > 0) return true + return super.canWrite(ctx) + } + + override async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const results = await super.writeProjectOutputs(ctx) + const {rules} = ctx.collectedInputContext + if (rules == null || rules.length === 0) return results + const ruleResults = [] + for (const project of ctx.collectedInputContext.workspace.projects) { + if (project.dirFromWorkspacePath == null) continue + const projectRules = applySubSeriesGlobPrefix( + filterRulesByProjectConfig( + rules.filter(r => this.normalizeRuleScope(r) === 'project'), + project.projectConfig + ), + project.projectConfig + ) + if (projectRules.length === 0) continue + const rulesDir = path.join(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path, PROJECT_RULES_DIR, RULES_SUBDIR) + for (const rule of projectRules) ruleResults.push(await this.writeFile(ctx, path.join(rulesDir, this.buildRuleFileName(rule)), this.buildRuleContent(rule), 'rule')) + } + return {files: [...results.files, ...ruleResults], dirs: results.dirs} + } +} diff --git a/cli/src/plugins/plugin-opencode-cli/index.ts b/cli/src/plugins/plugin-opencode-cli/index.ts new file mode 100644 index 00000000..7ce39288 --- /dev/null +++ b/cli/src/plugins/plugin-opencode-cli/index.ts @@ -0,0 +1,3 @@ +export { + OpencodeCLIOutputPlugin +} from './OpencodeCLIOutputPlugin' diff --git a/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.test.ts b/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.test.ts new file mode 100644 index 00000000..04dd185e --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.test.ts @@ -0,0 +1,717 @@ +import type { + AIAgentIgnoreConfigFile, + FastCommandPrompt, + OutputWriteContext, + PluginOptions, + Project, + RelativePath, + WriteResult +} from '@truenine/plugin-shared' +import type {FastCommandNameTransformOptions} from './AbstractOutputPlugin' + +import {FilePathKind, PromptKind} from '@truenine/plugin-shared' +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' +import {AbstractOutputPlugin} from './AbstractOutputPlugin' + +class TestOutputPlugin extends AbstractOutputPlugin { // Create a concrete test implementation + constructor(pluginName: string = 'TestOutputPlugin') { + super(pluginName, {outputFileName: 'TEST.md'}) + } + + public testExtractGlobalMemoryContent(ctx: OutputWriteContext) { // Expose protected methods for testing + return this.extractGlobalMemoryContent(ctx) + } + + public testCombineGlobalWithContent( + globalContent: string | undefined, + projectContent: string, + options?: any + ) { + return this.combineGlobalWithContent(globalContent, projectContent, options) + } + + public testTransformFastCommandName( + cmd: FastCommandPrompt, + options?: FastCommandNameTransformOptions + ) { + return this.transformFastCommandName(cmd, options) + } + + public testGetFastCommandSeriesOptions(ctx: OutputWriteContext) { + return this.getFastCommandSeriesOptions(ctx) + } + + public testGetTransformOptionsFromContext( + ctx: OutputWriteContext, + additionalOptions?: FastCommandNameTransformOptions + ) { + 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 { + 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 +} + +function createMockContext(globalContent?: string, pluginOptions?: PluginOptions): OutputWriteContext { + const hasGlobalContent = globalContent != null && globalContent.trim().length > 0 + return { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', '/test'), + projects: [] + }, + ideConfigFiles: [], + globalMemory: hasGlobalContent + ? { + type: PromptKind.GlobalMemory, + content: globalContent, + dir: createMockRelativePath('.', '/test'), + markdownContents: [], + length: globalContent.length, + filePathKind: FilePathKind.Relative, + parentDirectoryPath: { + type: 'UserHome', + directory: createMockRelativePath('.memory', '/home/user') + } + } as any + : (null as any) + } as any, + dryRun: false, + pluginOptions + } as unknown as OutputWriteContext +} + +describe('abstractOutputPlugin', () => { + describe('extractGlobalMemoryContent', () => { + it('should extract global memory content when present', () => { + const plugin = new TestOutputPlugin() + const ctx = createMockContext('Global content here') + + const result = plugin.testExtractGlobalMemoryContent(ctx) + + expect(result).toBe('Global content here') + }) + + it('should return undefined when global memory is not present', () => { + const plugin = new TestOutputPlugin() + const ctx = createMockContext() + + const result = plugin.testExtractGlobalMemoryContent(ctx) + + expect(result).toBeUndefined() + }) + + it('should return undefined when global memory content is undefined', () => { + const plugin = new TestOutputPlugin() + const ctx = createMockContext(); + (ctx.collectedInputContext as any).globalMemory = { + type: PromptKind.GlobalMemory, + dir: createMockRelativePath('.', '/test'), + markdownContents: [], + length: 0, + filePathKind: FilePathKind.Relative, + parentDirectoryPath: { + type: 'UserHome', + directory: createMockRelativePath('.memory', '/home/user') + } + } as any + + const result = plugin.testExtractGlobalMemoryContent(ctx) + + expect(result).toBeUndefined() + }) + }) + + describe('combineGlobalWithContent', () => { + it('should combine global and project content with default options', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent('Global', 'Project') + + expect(result).toBe('Global\n\nProject') + }) + + it('should skip empty global content by default', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent('', 'Project') + + expect(result).toBe('Project') + }) + + it('should skip whitespace-only global content by default', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent(' \n\n ', 'Project') + + expect(result).toBe('Project') + }) + + it('should skip undefined global content by default', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent(null as any, 'Project') + + expect(result).toBe('Project') + }) + + it('should use custom separator when provided', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent('Global', 'Project', {separator: '\n---\n'}) + + expect(result).toBe('Global\n---\nProject') + }) + + it('should place global content after when position is "after"', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent('Global', 'Project', {position: 'after'}) + + expect(result).toBe('Project\n\nGlobal') + }) + + it('should place global content before when position is "before"', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent('Global', 'Project', {position: 'before'}) + + expect(result).toBe('Global\n\nProject') + }) + + it('should not skip empty content when skipIfEmpty is false', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent('', 'Project', {skipIfEmpty: false}) + + expect(result).toBe('\n\nProject') + }) + + it('should not skip whitespace content when skipIfEmpty is false', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent(' ', 'Project', {skipIfEmpty: false}) + + expect(result).toBe(' \n\nProject') + }) + + it('should treat undefined as empty string when skipIfEmpty is false', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent(null as any, 'Project', {skipIfEmpty: false}) + + expect(result).toBe('\n\nProject') + }) + + it('should combine multiple options correctly', () => { + const plugin = new TestOutputPlugin() + const result = plugin.testCombineGlobalWithContent('Global', 'Project', {separator: '\n===\n', position: 'after', skipIfEmpty: true}) + + expect(result).toBe('Project\n===\nGlobal') + }) + + it('should handle multi-line content correctly', () => { + const plugin = new TestOutputPlugin() + const globalContent = '# Global Rules\n\nThese are global.' + const projectContent = '# Project Rules\n\nThese are project-specific.' + const result = plugin.testCombineGlobalWithContent(globalContent, projectContent) + + expect(result).toBe( + '# Global Rules\n\nThese are global.\n\n# Project Rules\n\nThese are project-specific.' + ) + }) + }) + + describe('transformFastCommandName', () => { + const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) // Generator for alphanumeric strings without underscore (for series prefix) + .filter(s => /^[a-z0-9]+$/i.test(s)) + + const alphanumericCommandName = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generator for alphanumeric strings (for command name) + .filter(s => /^\w+$/.test(s)) + + const separatorChar = fc.constantFrom('_', '-', '.', '~') // Generator for separator characters + + it('should include series prefix with default separator when includeSeriesPrefix is true or undefined', () => { + fc.assert( + fc.property( + alphanumericNoUnderscore, + alphanumericCommandName, + (series, commandName) => { + const plugin = new TestOutputPlugin() + const cmd = createMockFastCommandPrompt(series, commandName) + + const resultTrue = plugin.testTransformFastCommandName(cmd, {includeSeriesPrefix: true}) // Test with includeSeriesPrefix = true + expect(resultTrue).toBe(`${series}-${commandName}.md`) + + const resultDefault = plugin.testTransformFastCommandName(cmd) // Test with includeSeriesPrefix = undefined (default) + expect(resultDefault).toBe(`${series}-${commandName}.md`) + } + ), + {numRuns: 100} + ) + }) + + it('should exclude series prefix when includeSeriesPrefix is false', () => { + fc.assert( + fc.property( + alphanumericNoUnderscore, + alphanumericCommandName, + (series, commandName) => { + const plugin = new TestOutputPlugin() + const cmd = createMockFastCommandPrompt(series, commandName) + + const result = plugin.testTransformFastCommandName(cmd, {includeSeriesPrefix: false}) + expect(result).toBe(`${commandName}.md`) + } + ), + {numRuns: 100} + ) + }) + + it('should use configurable separator between series and command name', () => { + fc.assert( + fc.property( + alphanumericNoUnderscore, + alphanumericCommandName, + separatorChar, + (series, commandName, separator) => { + const plugin = new TestOutputPlugin() + const cmd = createMockFastCommandPrompt(series, commandName) + + const result = plugin.testTransformFastCommandName(cmd, {includeSeriesPrefix: true, seriesSeparator: separator}) + expect(result).toBe(`${series}${separator}${commandName}.md`) + } + ), + {numRuns: 100} + ) + }) + + it('should return just commandName.md when series is undefined', () => { + fc.assert( + fc.property( + alphanumericCommandName, + fc.boolean(), + separatorChar, + (commandName, includePrefix, separator) => { + const plugin = new TestOutputPlugin() + const cmd = createMockFastCommandPrompt(void 0, commandName) + + const result = plugin.testTransformFastCommandName(cmd, { // Regardless of includeSeriesPrefix setting, should return just commandName + includeSeriesPrefix: includePrefix, + seriesSeparator: separator + }) + expect(result).toBe(`${commandName}.md`) + } + ), + {numRuns: 100} + ) + }) + + it('should handle pe_compile correctly with default options', () => { // Unit tests for specific edge cases + const plugin = new TestOutputPlugin() + const cmd = createMockFastCommandPrompt('pe', 'compile') + + const result = plugin.testTransformFastCommandName(cmd) + expect(result).toBe('pe-compile.md') + }) + + it('should handle pe_compile with hyphen separator (Kiro style)', () => { + const plugin = new TestOutputPlugin() + const cmd = createMockFastCommandPrompt('pe', 'compile') + + const result = plugin.testTransformFastCommandName(cmd, {seriesSeparator: '-'}) + expect(result).toBe('pe-compile.md') + }) + + it('should handle command without series', () => { + const plugin = new TestOutputPlugin() + const cmd = createMockFastCommandPrompt(void 0, 'compile') + + const result = plugin.testTransformFastCommandName(cmd) + expect(result).toBe('compile.md') + }) + + it('should strip prefix when includeSeriesPrefix is false', () => { + const plugin = new TestOutputPlugin() + const cmd = createMockFastCommandPrompt('pe', 'compile') + + const result = plugin.testTransformFastCommandName(cmd, {includeSeriesPrefix: false}) + expect(result).toBe('compile.md') + }) + }) + + describe('getFastCommandSeriesOptions and getTransformOptionsFromContext', () => { + const pluginNameGen = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generator for plugin names + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const separatorGen = fc.constantFrom('_', '-', '.', '~') // Generator for separator characters + + it('should return plugin-specific override when it exists', () => { + fc.assert( + fc.property( + pluginNameGen, + fc.boolean(), + separatorGen, + fc.boolean(), + separatorGen, + (pluginName, globalInclude, _globalSep, pluginInclude, pluginSep) => { + const plugin = new TestOutputPlugin(pluginName) + const ctx = createMockContext(void 0, { + fastCommandSeriesOptions: { + includeSeriesPrefix: globalInclude, + pluginOverrides: { + [pluginName]: { + includeSeriesPrefix: pluginInclude, + seriesSeparator: pluginSep + } + } + } + }) + + const result = plugin.testGetFastCommandSeriesOptions(ctx) + + expect(result.includeSeriesPrefix).toBe(pluginInclude) // Plugin-specific override should take precedence + expect(result.seriesSeparator).toBe(pluginSep) + } + ), + {numRuns: 100} + ) + }) + + it('should fall back to global settings when no plugin override exists', () => { + fc.assert( + fc.property( + pluginNameGen, + fc.boolean(), + (pluginName, globalInclude) => { + const plugin = new TestOutputPlugin(pluginName) + const ctx = createMockContext(void 0, { + fastCommandSeriesOptions: { + includeSeriesPrefix: globalInclude + } + }) + + const result = plugin.testGetFastCommandSeriesOptions(ctx) + + expect(result.includeSeriesPrefix).toBe(globalInclude) // Should use global setting + expect(result.seriesSeparator).not.toBeDefined() // seriesSeparator should not be set + } + ), + {numRuns: 100} + ) + }) + + it('should return empty options when no configuration exists', () => { + fc.assert( + fc.property( + pluginNameGen, + pluginName => { + const plugin = new TestOutputPlugin(pluginName) + const ctx = createMockContext() + + const result = plugin.testGetFastCommandSeriesOptions(ctx) + + expect(result.includeSeriesPrefix).not.toBeDefined() + expect(result.seriesSeparator).not.toBeDefined() + } + ), + {numRuns: 100} + ) + }) + + it('should merge additionalOptions with config options in getTransformOptionsFromContext', () => { + fc.assert( + fc.property( + pluginNameGen, + fc.boolean(), + separatorGen, + separatorGen, + (pluginName, configInclude, configSep, additionalSep) => { + const plugin = new TestOutputPlugin(pluginName) + const ctx = createMockContext(void 0, { + fastCommandSeriesOptions: { + includeSeriesPrefix: configInclude, + pluginOverrides: { + [pluginName]: { + seriesSeparator: configSep + } + } + } + }) + + const result = plugin.testGetTransformOptionsFromContext(ctx, { // Config separator should override additional options + seriesSeparator: additionalSep + }) + + expect(result.includeSeriesPrefix).toBe(configInclude) + expect(result.seriesSeparator).toBe(configSep) // Config separator takes precedence over additional options + } + ), + {numRuns: 100} + ) + }) + + it('should use additionalOptions when config does not specify the option', () => { + fc.assert( + fc.property( + pluginNameGen, + fc.boolean(), + separatorGen, + (pluginName, additionalInclude, additionalSep) => { + const plugin = new TestOutputPlugin(pluginName) + const ctx = createMockContext() // No fastCommandSeriesOptions in config + + const result = plugin.testGetTransformOptionsFromContext(ctx, {includeSeriesPrefix: additionalInclude, seriesSeparator: additionalSep}) + + expect(result.includeSeriesPrefix).toBe(additionalInclude) // Should use additional options as fallback + expect(result.seriesSeparator).toBe(additionalSep) + } + ), + {numRuns: 100} + ) + }) + + it('should handle KiroCLIOutputPlugin style configuration', () => { // Unit tests for specific scenarios + const plugin = new TestOutputPlugin('KiroCLIOutputPlugin') + const ctx = createMockContext(void 0, { + fastCommandSeriesOptions: { + includeSeriesPrefix: false, + pluginOverrides: { + KiroCLIOutputPlugin: { + includeSeriesPrefix: true, + seriesSeparator: '-' + } + } + } + }) + + const result = plugin.testGetFastCommandSeriesOptions(ctx) + + expect(result.includeSeriesPrefix).toBe(true) // Plugin override should take precedence + expect(result.seriesSeparator).toBe('-') + }) + + it('should handle partial plugin override (only seriesSeparator)', () => { + const plugin = new TestOutputPlugin('TestPlugin') + const ctx = createMockContext(void 0, { + fastCommandSeriesOptions: { + includeSeriesPrefix: true, + pluginOverrides: { + TestPlugin: { + seriesSeparator: '-' + } + } + } + }) + + const result = plugin.testGetFastCommandSeriesOptions(ctx) + + expect(result.includeSeriesPrefix).toBe(true) // includeSeriesPrefix should fall back to global + 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/plugin-output-shared/AbstractOutputPlugin.ts b/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts new file mode 100644 index 00000000..59f3572e --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts @@ -0,0 +1,543 @@ +import type {CleanEffectHandler, EffectRegistration, EffectResult, FastCommandPrompt, ILogger, OutputCleanContext, OutputPlugin, OutputPluginContext, OutputWriteContext, Project, RegistryOperationResult, RulePrompt, RuleScope, WriteEffectHandler, WriteResult, WriteResults} from '@truenine/plugin-shared' +import type {FastCommandSeriesPluginOverride, Path, ProjectConfig, RegistryData, RelativePath} from '@truenine/plugin-shared/types' + +import type {Buffer} from 'node:buffer' +import type {RegistryWriter} from './registry/RegistryWriter' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import process from 'node:process' +import { + createFileRelativePath as deskCreateFileRelativePath, + createRelativePath as deskCreateRelativePath, + createSymlink as deskCreateSymlink, + ensureDir as deskEnsureDir, + isSymlink as deskIsSymlink, + lstatSync as deskLstatSync, + removeSymlink as deskRemoveSymlink, + writeFileSync as deskWriteFileSync +} from '@truenine/desk-paths' +import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' +import { + AbstractPlugin, + FilePathKind, + PluginKind +} from '@truenine/plugin-shared' + +/** + * Options for transforming fast command names in output filenames. + * Used by transformFastCommandName method to control prefix handling. + */ +export interface FastCommandNameTransformOptions { + readonly includeSeriesPrefix?: boolean + readonly seriesSeparator?: string +} + +/** + * Options for configuring AbstractOutputPlugin subclasses. + */ +export interface AbstractOutputPluginOptions { + globalConfigDir?: string + + outputFileName?: string + + dependsOn?: readonly string[] + + indexignore?: string +} + +/** + * Options for combining global content with project content. + */ +export interface CombineOptions { + separator?: string + + skipIfEmpty?: boolean + + position?: 'before' | 'after' +} + +export abstract class AbstractOutputPlugin extends AbstractPlugin implements OutputPlugin { + protected readonly globalConfigDir: string + + protected readonly outputFileName: string + + protected readonly indexignore: string | undefined + + private readonly registryWriterCache: Map> = new Map() + + private readonly writeEffects: EffectRegistration[] = [] + + private readonly cleanEffects: EffectRegistration[] = [] + + protected constructor(name: string, options?: AbstractOutputPluginOptions) { + super(name, PluginKind.Output, options?.dependsOn) + this.globalConfigDir = options?.globalConfigDir ?? '' + this.outputFileName = options?.outputFileName ?? '' + this.indexignore = options?.indexignore + } + + protected resolvePromptSourceProjectConfig(ctx: OutputPluginContext | OutputWriteContext): ProjectConfig | undefined { + const {projects} = ctx.collectedInputContext.workspace + const promptSource = projects.find(p => p.isPromptSourceProject === true) + return promptSource?.projectConfig ?? projects[0]?.projectConfig + } + + protected registerWriteEffect(name: string, handler: WriteEffectHandler): void { + this.writeEffects.push({name, handler}) + } + + protected registerCleanEffect(name: string, handler: CleanEffectHandler): void { + this.cleanEffects.push({name, handler}) + } + + protected async executeWriteEffects(ctx: OutputWriteContext): Promise { + const results: EffectResult[] = [] + + for (const effect of this.writeEffects) { + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'effect', name: effect.name}) + results.push({success: true, description: `Would execute write effect: ${effect.name}`}) + continue + } + + try { + const result = await effect.handler(ctx) + if (result.success) this.log.trace({action: 'effect', name: effect.name, status: 'success'}) + else { + const errorMsg = result.error instanceof Error ? result.error.message : String(result.error) + this.log.error({action: 'effect', name: effect.name, status: 'failed', error: errorMsg}) + } + results.push(result) + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'effect', name: effect.name, status: 'failed', error: errorMsg}) + results.push({success: false, error: error as Error, description: `Write effect failed: ${effect.name}`}) + } + } + + return results + } + + protected async executeCleanEffects(ctx: OutputCleanContext): Promise { + const results: EffectResult[] = [] + + for (const effect of this.cleanEffects) { + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'effect', name: effect.name}) + results.push({success: true, description: `Would execute clean effect: ${effect.name}`}) + continue + } + + try { + const result = await effect.handler(ctx) + if (result.success) this.log.trace({action: 'effect', name: effect.name, status: 'success'}) + else { + const errorMsg = result.error instanceof Error ? result.error.message : String(result.error) + this.log.error({action: 'effect', name: effect.name, status: 'failed', error: errorMsg}) + } + results.push(result) + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'effect', name: effect.name, status: 'failed', error: errorMsg}) + results.push({success: false, error: error as Error, description: `Clean effect failed: ${effect.name}`}) + } + } + + return results + } + + protected isRelativePath(p: Path): p is RelativePath { + return p.pathKind === FilePathKind.Relative + } + + protected toRelativePath(p: Path): RelativePath { + if (this.isRelativePath(p)) return p + return { // Fallback for non-relative paths + pathKind: FilePathKind.Relative, + path: p.path, + basePath: '', + getDirectoryName: p.getDirectoryName, + getAbsolutePath: () => p.path + } + } + + protected resolveFullPath(targetPath: Path, outputFileName?: string): string { + let dirPath: string + if (targetPath.pathKind === FilePathKind.Absolute) dirPath = targetPath.path + else if (this.isRelativePath(targetPath)) dirPath = path.resolve(targetPath.basePath, targetPath.path) + else dirPath = path.resolve(process.cwd(), targetPath.path) + + const fileName = outputFileName ?? this.outputFileName // Append the output file name if provided or if default is set + if (fileName) return path.join(dirPath, fileName) + return dirPath + } + + protected createRelativePath( + pathStr: string, + basePath: string, + dirNameFn: () => string + ): RelativePath { + return deskCreateRelativePath(pathStr, basePath, dirNameFn) + } + + protected createFileRelativePath(dir: RelativePath, fileName: string): RelativePath { + return deskCreateFileRelativePath(dir, fileName) + } + + protected getGlobalConfigDir(): string { + return path.join(this.getHomeDir(), this.globalConfigDir) + } + + protected getHomeDir(): string { + return os.homedir() + } + + protected joinPath(...segments: string[]): string { + return path.join(...segments) + } + + protected resolvePath(...segments: string[]): string { + return path.resolve(...segments) + } + + protected dirname(p: string): string { + return path.dirname(p) + } + + protected basename(p: string, ext?: string): string { + return path.basename(p, ext) + } + + protected writeFileSync(filePath: string, content: string, encoding: BufferEncoding = 'utf8'): void { + deskWriteFileSync(filePath, content, encoding) + } + + protected writeFileSyncBuffer(filePath: string, buffer: Buffer): void { + deskWriteFileSync(filePath, buffer) + } + + protected ensureDirectory(dir: string): void { + deskEnsureDir(dir) + } + + protected existsSync(p: string): boolean { + return fs.existsSync(p) + } + + protected lstatSync(p: string): fs.Stats { + return deskLstatSync(p) + } + + protected isSymlink(p: string): boolean { + return deskIsSymlink(p) + } + + protected createSymlink(targetPath: string, symlinkPath: string, type: 'file' | 'dir' = 'dir'): void { + deskCreateSymlink(targetPath, symlinkPath, type) + } + + protected removeSymlink(symlinkPath: string): void { + deskRemoveSymlink(symlinkPath) + } + + protected async writeDirectorySymlink( + ctx: OutputWriteContext, + targetPath: string, + symlinkPath: string, + label: string + ): Promise { + const dir = path.dirname(symlinkPath) + const linkName = path.basename(symlinkPath) + const relativePath: RelativePath = deskCreateRelativePath(linkName, dir, () => path.basename(dir)) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'symlink', target: targetPath, link: symlinkPath, label}) + return {path: relativePath, success: true, skipped: false} + } + + try { + this.createSymlink(targetPath, symlinkPath, 'dir') + this.log.trace({action: 'write', type: 'symlink', target: targetPath, link: symlinkPath, label}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'symlink', target: targetPath, link: symlinkPath, label, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + protected readdirSync(dir: string, options: {withFileTypes: true}): fs.Dirent[] + protected readdirSync(dir: string): string[] + protected readdirSync(dir: string, options?: {withFileTypes?: boolean}): fs.Dirent[] | string[] { + if (options?.withFileTypes === true) return fs.readdirSync(dir, {withFileTypes: true}) + return fs.readdirSync(dir) + } + + protected getIgnoreOutputPath(): string | undefined { + if (this.indexignore == null) return void 0 + return this.indexignore + } + + protected registerProjectIgnoreOutputFiles(projects: readonly Project[]): RelativePath[] { + const outputPath = this.getIgnoreOutputPath() + if (outputPath == null) return [] + + const results: RelativePath[] = [] + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + if (project.isPromptSourceProject === true) continue + + const filePath = path.join(projectDir.path, outputPath) + results.push({ + pathKind: FilePathKind.Relative, + path: filePath, + basePath: projectDir.basePath, + getDirectoryName: () => 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 { + fs.mkdirSync(path.dirname(fullPath), {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, + content: string, + label: string + ): Promise { + const dir = path.dirname(fullPath) // Create a relative path for the result + const fileName = path.basename(fullPath) + const relativePath: RelativePath = deskCreateRelativePath(fileName, dir, () => path.basename(dir)) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'file', path: fullPath, label}) + return {path: relativePath, success: true, skipped: false} + } + + try { + deskWriteFileSync(fullPath, content) + this.log.trace({action: 'write', type: 'file', 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: 'file', path: fullPath, label, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + protected async writePromptFile( + ctx: OutputWriteContext, + targetPath: Path, + content: string, + label: string + ): Promise { + const fullPath = this.resolveFullPath(targetPath) + const relativePath = this.toRelativePath(targetPath) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'promptFile', path: fullPath, label}) + return {path: relativePath, success: true, skipped: false} + } + + try { + deskWriteFileSync(fullPath, content) + this.log.trace({action: 'write', type: 'promptFile', 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: 'promptFile', path: fullPath, label, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + protected buildMarkdownContent(content: string, frontMatter?: Record): string { + return buildMarkdownWithFrontMatter(frontMatter, content) + } + + protected buildMarkdownContentWithRaw( + content: string, + frontMatter?: Record, + rawFrontMatter?: string + ): string { + if (frontMatter != null && Object.keys(frontMatter).length > 0) return buildMarkdownWithFrontMatter(frontMatter, content) // If we have parsed front matter, use it + + if (rawFrontMatter != null && rawFrontMatter.length > 0) return `---\n${rawFrontMatter}\n---\n${content}` // If we have raw front matter but parsing failed, use raw + + return content // No front matter + } + + protected extractGlobalMemoryContent(ctx: OutputWriteContext): string | undefined { + return ctx.collectedInputContext.globalMemory?.content as string | undefined + } + + protected combineGlobalWithContent( + globalContent: string | undefined, + projectContent: string, + options?: CombineOptions + ): string { + const { + separator = '\n\n', + skipIfEmpty = true, + position = 'before' + } = options ?? {} + + if (skipIfEmpty && (globalContent == null || globalContent.trim().length === 0)) return projectContent // Skip if global content is undefined/null or empty/whitespace when skipIfEmpty is true + + const effectiveGlobalContent = globalContent ?? '' // If global content is null/undefined but skipIfEmpty is false, treat as empty string + + if (position === 'after') return `${projectContent}${separator}${effectiveGlobalContent}` // Combine based on position + + return `${effectiveGlobalContent}${separator}${projectContent}` // Default: 'before' + } + + protected transformFastCommandName( + cmd: FastCommandPrompt, + options?: FastCommandNameTransformOptions + ): string { + const {includeSeriesPrefix = true, seriesSeparator = '-'} = options ?? {} + + if (!includeSeriesPrefix || cmd.series == null) return `${cmd.commandName}.md` // If prefix should not be included or series is not present, return just commandName + + return `${cmd.series}${seriesSeparator}${cmd.commandName}.md` + } + + protected getFastCommandSeriesOptions(ctx: OutputWriteContext): FastCommandSeriesPluginOverride { + const globalOptions = ctx.pluginOptions?.fastCommandSeriesOptions + const pluginOverride = globalOptions?.pluginOverrides?.[this.name] + + const includeSeriesPrefix = pluginOverride?.includeSeriesPrefix ?? globalOptions?.includeSeriesPrefix // Only include properties that have defined values to satisfy exactOptionalPropertyTypes // Plugin-specific overrides take precedence over global settings + const seriesSeparator = pluginOverride?.seriesSeparator + + if (includeSeriesPrefix != null && seriesSeparator != null) return {includeSeriesPrefix, seriesSeparator} // Build result object conditionally to avoid assigning undefined to readonly properties + if (includeSeriesPrefix != null) return {includeSeriesPrefix} + if (seriesSeparator != null) return {seriesSeparator} + return {} + } + + protected getTransformOptionsFromContext( + ctx: OutputWriteContext, + additionalOptions?: FastCommandNameTransformOptions + ): FastCommandNameTransformOptions { + const seriesOptions = this.getFastCommandSeriesOptions(ctx) + + const includeSeriesPrefix = seriesOptions.includeSeriesPrefix ?? additionalOptions?.includeSeriesPrefix // Only include properties that have defined values to satisfy exactOptionalPropertyTypes // Merge: additionalOptions (plugin defaults) <- seriesOptions (config overrides) + const seriesSeparator = seriesOptions.seriesSeparator ?? additionalOptions?.seriesSeparator + + if (includeSeriesPrefix != null && seriesSeparator != null) return {includeSeriesPrefix, seriesSeparator} // Build result object conditionally to avoid assigning undefined to readonly properties + if (includeSeriesPrefix != null) return {includeSeriesPrefix} + if (seriesSeparator != null) return {seriesSeparator} + return {} + } + + protected shouldSkipDueToPlugin(ctx: OutputWriteContext, precedingPluginName: string): boolean { + const registeredPlugins = ctx.registeredPluginNames + if (registeredPlugins == null) return false + return registeredPlugins.includes(precedingPluginName) + } + + async onWriteComplete(ctx: OutputWriteContext, results: WriteResults): Promise { + const success = results.files.filter(r => r.success).length + const skipped = results.files.filter(r => r.skipped).length + const failed = results.files.filter(r => !r.success && !r.skipped).length + + this.log.trace({action: ctx.dryRun === true ? 'dryRun' : 'complete', type: 'writeSummary', success, skipped, failed}) + + await this.executeWriteEffects(ctx) // Execute registered write effects + } + + async onCleanComplete(ctx: OutputCleanContext): Promise { + await this.executeCleanEffects(ctx) // Execute registered clean effects + } + + protected getRegistryWriter< + TEntry, + TRegistry extends RegistryData, + T extends RegistryWriter + >( + WriterClass: new (logger: ILogger) => T + ): T { + const cacheKey = WriterClass.name + + const cached = this.registryWriterCache.get(cacheKey) // Check cache first + if (cached != null) return cached as T + + const writer = new WriterClass(this.log) // Create new instance and cache it + this.registryWriterCache.set(cacheKey, writer as RegistryWriter) + return writer + } + + protected async registerInRegistry< + TEntry, + TRegistry extends RegistryData + >( + writer: RegistryWriter, + entries: readonly TEntry[], + ctx: OutputWriteContext + ): Promise { + return writer.register(entries, ctx.dryRun) + } + + protected normalizeRuleScope(rule: RulePrompt): RuleScope { + return rule.scope ?? 'project' + } +} diff --git a/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts b/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts new file mode 100644 index 00000000..3c58599a --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts @@ -0,0 +1,405 @@ +import type { + FastCommandPrompt, + OutputPluginContext, + OutputWriteContext, + RulePrompt, + RuleScope, + SkillPrompt, + SubAgentPrompt, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import type {AbstractOutputPluginOptions} from './AbstractOutputPlugin' +import * as path from 'node:path' +import {writeFileSync as deskWriteFileSync} from '@truenine/desk-paths' +import {mdxToMd} from '@truenine/md-compiler' +import {GlobalScopeCollector} from '@truenine/plugin-input-shared/scope' +import {AbstractOutputPlugin} from './AbstractOutputPlugin' +import {filterCommandsByProjectConfig, filterSkillsByProjectConfig, filterSubAgentsByProjectConfig} from './utils' + +export interface BaseCLIOutputPluginOptions extends AbstractOutputPluginOptions { + readonly commandsSubDir?: string + readonly agentsSubDir?: string + readonly skillsSubDir?: string + + readonly supportsFastCommands?: boolean + + readonly supportsSubAgents?: boolean + + readonly supportsSkills?: boolean + + readonly toolPreset?: string +} + +export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { + protected readonly commandsSubDir: string + protected readonly agentsSubDir: string + protected readonly skillsSubDir: string + protected readonly supportsFastCommands: boolean + protected readonly supportsSubAgents: boolean + protected readonly supportsSkills: boolean + protected readonly toolPreset?: string + + constructor(name: string, options: BaseCLIOutputPluginOptions) { + super(name, options) + this.commandsSubDir = options.commandsSubDir ?? 'commands' + this.agentsSubDir = options.agentsSubDir ?? 'agents' + this.skillsSubDir = options.skillsSubDir ?? 'skills' + this.supportsFastCommands = options.supportsFastCommands ?? true + this.supportsSubAgents = options.supportsSubAgents ?? true + this.supportsSkills = options.supportsSkills ?? true + if (options.toolPreset !== void 0) this.toolPreset = options.toolPreset + } + + async registerGlobalOutputDirs(_ctx: OutputPluginContext): Promise { + const globalDir = this.getGlobalConfigDir() + const results: RelativePath[] = [] + const subdirs: string[] = [] + + if (this.supportsFastCommands) subdirs.push(this.commandsSubDir) + if (this.supportsSubAgents) subdirs.push(this.agentsSubDir) + if (this.supportsSkills) subdirs.push(this.skillsSubDir) + + for (const subdir of subdirs) results.push(this.createRelativePath(subdir, globalDir, () => subdir)) + + return results + } + + async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + + const subdirs: string[] = [] // Subdirectories might be needed there too // Most CLI tools store project-local config in a hidden folder .toolname + if (this.supportsFastCommands) subdirs.push(this.commandsSubDir) + if (this.supportsSubAgents) subdirs.push(this.agentsSubDir) + if (this.supportsSkills) subdirs.push(this.skillsSubDir) + + if (subdirs.length === 0) return [] + + for (const project of projects) { + if (project.dirFromWorkspacePath == null) continue + + for (const subdir of subdirs) { + const dirPath = path.join(project.dirFromWorkspacePath.path, this.globalConfigDir, subdir) // Assuming globalConfigDir is something like .claude + results.push(this.createRelativePath(dirPath, project.dirFromWorkspacePath.basePath, () => subdir)) + } + } + + return results + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + + for (const project of projects) { + if (project.rootMemoryPrompt != null && project.dirFromWorkspacePath != null) { // Root memory file + results.push(this.createFileRelativePath(project.dirFromWorkspacePath, this.outputFileName)) + } + + if (project.childMemoryPrompts != null) { // Child memory files + for (const child of project.childMemoryPrompts) { + if (child.dir != null && this.isRelativePath(child.dir)) results.push(this.createFileRelativePath(child.dir, this.outputFileName)) + } + } + } + + return results + } + + async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { + const {globalMemory} = ctx.collectedInputContext + if (globalMemory == null) return [] + + const globalDir = this.getGlobalConfigDir() + const results: RelativePath[] = [ + this.createRelativePath(this.outputFileName, globalDir, () => this.globalConfigDir) + ] + + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const {fastCommands, subAgents, skills} = ctx.collectedInputContext + const transformOptions = {includeSeriesPrefix: true} as const + + if (this.supportsFastCommands && fastCommands != null) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + for (const cmd of filteredCommands) { + const fileName = this.transformFastCommandName(cmd, transformOptions) + results.push(this.createRelativePath(path.join(this.commandsSubDir, fileName), globalDir, () => this.commandsSubDir)) + } + } + + if (this.supportsSubAgents && subAgents != null) { + const filteredSubAgents = filterSubAgentsByProjectConfig(subAgents, projectConfig) + for (const agent of filteredSubAgents) { + const fileName = agent.dir.path.replace(/\.mdx$/, '.md') + results.push(this.createRelativePath(path.join(this.agentsSubDir, fileName), globalDir, () => this.agentsSubDir)) + } + } + + if (this.supportsSkills && skills == null) return results + if (skills == null) return results + + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const skillDir = path.join(this.skillsSubDir, skillName) + + results.push(this.createRelativePath(path.join(skillDir, 'SKILL.md'), globalDir, () => skillName)) + + if (skill.childDocs != null) { + for (const refDoc of skill.childDocs) { + const refDocFileName = refDoc.dir.path.replace(/\.mdx$/, '.md') + const refDocPath = path.join(skillDir, refDocFileName) + results.push(this.createRelativePath(refDocPath, globalDir, () => skillName)) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + const resourcePath = path.join(skillDir, resource.relativePath) + results.push(this.createRelativePath(resourcePath, globalDir, () => skillName)) + } + } + } + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {workspace, globalMemory, fastCommands, subAgents, skills} = ctx.collectedInputContext + const hasProjectOutputs = workspace.projects.some( + p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 + ) + const hasGlobalMemory = globalMemory != null + const hasFastCommands = this.supportsFastCommands && (fastCommands?.length ?? 0) > 0 + const hasSubAgents = this.supportsSubAgents && (subAgents?.length ?? 0) > 0 + const hasSkills = this.supportsSkills && (skills?.length ?? 0) > 0 + + if (hasProjectOutputs || hasGlobalMemory || hasFastCommands || hasSubAgents || hasSkills) return true + + this.log.trace({action: 'skip', reason: 'noOutputs'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + for (const project of projects) { + const projectName = project.name ?? 'unknown' + const projectDir = project.dirFromWorkspacePath + + if (projectDir == null) continue + + if (project.rootMemoryPrompt != null) { + const result = await this.writePromptFile(ctx, projectDir, project.rootMemoryPrompt.content as string, `project:${projectName}/root`) + fileResults.push(result) + } + + if (project.childMemoryPrompts != null) { + for (const child of project.childMemoryPrompts) { + const childResult = await this.writePromptFile(ctx, child.dir, child.content as string, `project:${projectName}/child:${child.workingChildDirectoryPath?.path ?? 'unknown'}`) + fileResults.push(childResult) + } + } + } + + return {files: fileResults, dirs: dirResults} + } + + async writeGlobalOutputs(ctx: OutputWriteContext): Promise { + const {globalMemory} = ctx.collectedInputContext + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + const checkList = [ + {enabled: true, data: globalMemory}, + {enabled: this.supportsFastCommands, data: ctx.collectedInputContext.fastCommands}, + {enabled: this.supportsSubAgents, data: ctx.collectedInputContext.subAgents}, + {enabled: this.supportsSkills, data: ctx.collectedInputContext.skills} + ] + + if (checkList.every(item => !item.enabled || item.data == null)) return {files: fileResults, dirs: dirResults} + + const {fastCommands, subAgents, skills} = ctx.collectedInputContext + const globalDir = this.getGlobalConfigDir() + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + + if (globalMemory != null) { // Write Global Memory File + const fullPath = path.join(globalDir, this.outputFileName) + const relativePath: RelativePath = this.createRelativePath(this.outputFileName, globalDir, () => this.globalConfigDir) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'globalMemory', path: fullPath}) + fileResults.push({ + path: relativePath, + success: true, + skipped: false + }) + } else { + try { + deskWriteFileSync(fullPath, globalMemory.content as string) + this.log.trace({action: 'write', type: 'globalMemory', path: fullPath}) + fileResults.push({path: relativePath, success: true}) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'globalMemory', path: fullPath, error: errMsg}) + fileResults.push({path: relativePath, success: false, error: error as Error}) + } + } + } + + if (this.supportsFastCommands && fastCommands != null) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + for (const cmd of filteredCommands) { + const cmdResults = await this.writeFastCommand(ctx, globalDir, cmd) + fileResults.push(...cmdResults) + } + } + + if (this.supportsSubAgents && subAgents != null) { + const filteredSubAgents = filterSubAgentsByProjectConfig(subAgents, projectConfig) + for (const agent of filteredSubAgents) { + const agentResults = await this.writeSubAgent(ctx, globalDir, agent) + fileResults.push(...agentResults) + } + } + + if (this.supportsSkills && skills == null) return {files: fileResults, dirs: dirResults} + if (skills == null) return {files: fileResults, dirs: dirResults} + + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) { + const skillResults = await this.writeSkill(ctx, globalDir, skill) + fileResults.push(...skillResults) + } + return {files: fileResults, dirs: dirResults} + } + + protected async writeFastCommand( + ctx: OutputWriteContext, + basePath: string, + cmd: FastCommandPrompt + ): Promise { + const transformOptions = this.getTransformOptionsFromContext(ctx) + const fileName = this.transformFastCommandName(cmd, transformOptions) + const targetDir = path.join(basePath, this.commandsSubDir) + const fullPath = path.join(targetDir, fileName) + + let compiledContent = cmd.content + let compiledFrontMatter = cmd.yamlFrontMatter + let useRecompiledFrontMatter = false + + if (cmd.rawMdxContent != null && this.toolPreset != null) { // Only recompile if we have raw content AND a tool preset is configured + this.log.debug('recompiling fast command with tool preset', { + file: cmd.dir.getAbsolutePath(), + toolPreset: this.toolPreset, + hasRawContent: true + }) + try { + // eslint-disable-next-line ts/no-unsafe-assignment + const scopeCollector = new GlobalScopeCollector({toolPreset: this.toolPreset as any}) // Cast to clean + const globalScope = scopeCollector.collect() + const result = await mdxToMd(cmd.rawMdxContent, {globalScope, extractMetadata: true, basePath: cmd.dir.basePath}) + compiledContent = result.content + compiledFrontMatter = result.metadata.fields as typeof cmd.yamlFrontMatter + useRecompiledFrontMatter = true + } + catch (e) { + this.log.warn('failed to recompile fast command, using default', { + file: cmd.dir.getAbsolutePath(), + error: e instanceof Error ? e.message : String(e) + }) + } + } + + const content = useRecompiledFrontMatter + ? this.buildMarkdownContent(compiledContent, compiledFrontMatter) + : this.buildMarkdownContentWithRaw(compiledContent, compiledFrontMatter, cmd.rawFrontMatter) + + return [await this.writeFile(ctx, fullPath, content, 'fastCommand')] + } + + protected async writeSubAgent( + ctx: OutputWriteContext, + basePath: string, + agent: SubAgentPrompt + ): Promise { + const fileName = agent.dir.path.replace(/\.mdx$/, '.md') + const targetDir = path.join(basePath, this.agentsSubDir) + const fullPath = path.join(targetDir, fileName) + + const content = this.buildMarkdownContentWithRaw( + agent.content, + agent.yamlFrontMatter, + agent.rawFrontMatter + ) + + return [await this.writeFile(ctx, fullPath, content, 'subAgent')] + } + + protected async writeSkill( + ctx: OutputWriteContext, + basePath: string, + skill: SkillPrompt + ): Promise { + const results: WriteResult[] = [] + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const targetDir = path.join(basePath, this.skillsSubDir, skillName) + const fullPath = path.join(targetDir, 'SKILL.md') + + const content = this.buildMarkdownContentWithRaw( + skill.content as string, + skill.yamlFrontMatter, + skill.rawFrontMatter + ) + + const mainFileResult = await this.writeFile(ctx, fullPath, content, 'skill') + results.push(mainFileResult) + + if (skill.childDocs != null) { + for (const refDoc of skill.childDocs) { + const refResults = await this.writeSkillReferenceDocument(ctx, targetDir, skillName, refDoc, basePath) + results.push(...refResults) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + const refResults = await this.writeSkillResource(ctx, targetDir, skillName, resource, basePath) + results.push(...refResults) + } + } + + return results + } + + protected async writeSkillReferenceDocument( + ctx: OutputWriteContext, + skillDir: string, + _skillName: string, + refDoc: {dir: RelativePath, content: unknown}, + _basePath: string + ): Promise { + const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') + const fullPath = path.join(skillDir, fileName) + return [await this.writeFile(ctx, fullPath, refDoc.content as string, 'skillRefDoc')] + } + + protected async writeSkillResource( + ctx: OutputWriteContext, + skillDir: string, + _skillName: string, + resource: {relativePath: string, content: string}, + _basePath: string + ): Promise { + const fullPath = path.join(skillDir, resource.relativePath) + return [await this.writeFile(ctx, fullPath, resource.content, 'skillResource')] + } + + protected override normalizeRuleScope(rule: RulePrompt): RuleScope { + return rule.scope ?? 'project' + } +} diff --git a/cli/src/plugins/plugin-output-shared/index.ts b/cli/src/plugins/plugin-output-shared/index.ts new file mode 100644 index 00000000..00e937bd --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/index.ts @@ -0,0 +1,14 @@ +export { + AbstractOutputPlugin +} from './AbstractOutputPlugin' +export type { + AbstractOutputPluginOptions, + CombineOptions, + FastCommandNameTransformOptions +} from './AbstractOutputPlugin' +export { + BaseCLIOutputPlugin +} from './BaseCLIOutputPlugin' +export type { + BaseCLIOutputPluginOptions +} from './BaseCLIOutputPlugin' diff --git a/cli/src/plugins/plugin-output-shared/registry/RegistryWriter.ts b/cli/src/plugins/plugin-output-shared/registry/RegistryWriter.ts new file mode 100644 index 00000000..3721cba1 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/registry/RegistryWriter.ts @@ -0,0 +1,149 @@ +/** + * Registry Configuration Writer + * + * Abstract base class for registry configuration writers. + * Provides common functionality for reading, writing, and merging JSON registry files. + * + * @see Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 7.1, 7.2 + */ + +import type {ILogger} from '@truenine/plugin-shared' +import type {RegistryData, RegistryOperationResult} from '@truenine/plugin-shared/types' + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import {createLogger} from '@truenine/plugin-shared' + +/** + * Abstract base class for registry configuration writers. + * Provides common functionality for reading, writing, and merging JSON registry files. + * + * @template TEntry - The type of entries stored in the registry + * @template TRegistry - The full registry data structure type + * + * @see Requirements 1.1, 1.2, 1.3, 1.7 + */ +export abstract class RegistryWriter< + TEntry, + TRegistry extends RegistryData = RegistryData +> { + protected readonly registryPath: string + + protected readonly log: ILogger + + protected constructor(registryPath: string, logger?: ILogger) { + this.registryPath = this.resolvePath(registryPath) + this.log = logger ?? createLogger(this.constructor.name) + } + + protected resolvePath(p: string): string { + if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1)) + return path.resolve(p) + } + + protected getRegistryDir(): string { + return path.dirname(this.registryPath) + } + + protected ensureRegistryDir(): void { + const dir = this.getRegistryDir() + if (!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true}) + } + + read(): TRegistry { + if (!fs.existsSync(this.registryPath)) { + this.log.debug('registry not found', {path: this.registryPath}) + return this.createInitialRegistry() + } + + try { + const content = fs.readFileSync(this.registryPath, 'utf8') + return JSON.parse(content) as TRegistry + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error('parse failed', {path: this.registryPath, error: errMsg}) + return this.createInitialRegistry() + } + } + + protected write(data: TRegistry, dryRun?: boolean): boolean { + const updatedData = { // Update lastUpdated timestamp + ...data, + lastUpdated: new Date().toISOString() + } as TRegistry + + if (dryRun === true) { + this.log.trace({action: 'dryRun', type: 'registry', path: this.registryPath}) + return true + } + + const tempPath = `${this.registryPath}.tmp.${Date.now()}` + + try { + this.ensureRegistryDir() + + const content = JSON.stringify(updatedData, null, 2) // Write to temporary file first + fs.writeFileSync(tempPath, content, 'utf8') + + fs.renameSync(tempPath, this.registryPath) // Atomic rename to replace target + + this.log.trace({action: 'write', type: 'registry', path: this.registryPath}) + return true + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'registry', path: this.registryPath, error: errMsg}) + + try { // Cleanup temp file if it exists + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath) + } + catch { + } // Ignore cleanup errors + + return false + } + } + + register( + entries: readonly TEntry[], + dryRun?: boolean + ): readonly RegistryOperationResult[] { + const results: RegistryOperationResult[] = [] + + const existing = this.read() // Read existing registry + + const merged = this.merge(existing, entries) // Merge new entries + + const writeSuccess = this.write(merged, dryRun) // Write updated registry + + for (const entry of entries) { // Build results for each entry + const entryName = this.getEntryName(entry) + if (writeSuccess) { + results.push({success: true, entryName}) + if (dryRun === true) this.log.trace({action: 'dryRun', type: 'registerEntry', entryName}) + else this.log.trace({action: 'register', type: 'entry', entryName}) + } else { + results.push({success: false, entryName, error: new Error(`Failed to write registry file`)}) + this.log.error('register entry failed', {entryName}) + } + } + + return results + } + + protected generateEntryId(prefix?: string): string { + const timestamp = Date.now() + const random = Math.random().toString(36).slice(2, 8) + const id = `${timestamp}-${random}` + return prefix != null ? `${prefix}-${id}` : id + } + + protected abstract getEntryName(entry: TEntry): string + + protected abstract merge(existing: TRegistry, entries: readonly TEntry[]): TRegistry + + protected abstract createInitialRegistry(): TRegistry +} diff --git a/cli/src/plugins/plugin-output-shared/registry/index.ts b/cli/src/plugins/plugin-output-shared/registry/index.ts new file mode 100644 index 00000000..658667cd --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/registry/index.ts @@ -0,0 +1,3 @@ +export { + RegistryWriter +} from './RegistryWriter' diff --git a/cli/src/plugins/plugin-output-shared/utils/commandFilter.ts b/cli/src/plugins/plugin-output-shared/utils/commandFilter.ts new file mode 100644 index 00000000..f3446593 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/commandFilter.ts @@ -0,0 +1,11 @@ +import type {FastCommandPrompt} from '@truenine/plugin-shared' +import type {ProjectConfig} from '@truenine/plugin-shared/types' +import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' + +export function filterCommandsByProjectConfig( + commands: readonly FastCommandPrompt[], + projectConfig: ProjectConfig | undefined +): readonly FastCommandPrompt[] { + const effectiveSeries = resolveEffectiveIncludeSeries(projectConfig?.includeSeries, projectConfig?.commands?.includeSeries) + return commands.filter(command => matchesSeries(command.seriName, effectiveSeries)) +} diff --git a/cli/src/plugins/plugin-output-shared/utils/gitUtils.ts b/cli/src/plugins/plugin-output-shared/utils/gitUtils.ts new file mode 100644 index 00000000..eace5421 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/gitUtils.ts @@ -0,0 +1,121 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +/** + * Resolves the actual `.git/info` directory for a given project path. + * Handles both regular git repos (`.git` is a directory) and submodules/worktrees (`.git` is a file with `gitdir:` pointer). + * Returns `null` if no valid git info directory can be resolved. + */ +export function resolveGitInfoDir(projectDir: string): string | null { + const dotGitPath = path.join(projectDir, '.git') + + if (!fs.existsSync(dotGitPath)) return null + + const stat = fs.lstatSync(dotGitPath) + + if (stat.isDirectory()) { + const infoDir = path.join(dotGitPath, 'info') + return infoDir + } + + if (stat.isFile()) { + try { + const content = fs.readFileSync(dotGitPath, 'utf8').trim() + const match = /^gitdir: (.+)$/.exec(content) + if (match?.[1] != null) { + const gitdir = path.resolve(projectDir, match[1]) + return path.join(gitdir, 'info') + } + } + catch { /* ignore read errors */ } + } + + return null +} + +/** + * Recursively discovers all `.git` entries (directories or files) under a given root, + * skipping common non-source directories. + * Returns absolute paths of directories containing a `.git` entry. + */ +export function findAllGitRepos(rootDir: string, maxDepth = 5): string[] { + const results: string[] = [] + const SKIP_DIRS = new Set(['node_modules', '.turbo', 'dist', 'build', 'out', '.cache']) + + function walk(dir: string, depth: number): void { + if (depth > maxDepth) return + + let entries: fs.Dirent[] + try { + const raw = fs.readdirSync(dir, {withFileTypes: true}) + if (!Array.isArray(raw)) return + entries = raw + } + catch { return } + + const hasGit = entries.some(e => e.name === '.git') + if (hasGit && dir !== rootDir) results.push(dir) + + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (entry.name === '.git' || SKIP_DIRS.has(entry.name)) continue + walk(path.join(dir, entry.name), depth + 1) + } + } + + walk(rootDir, 0) + return results +} + +/** + * Scans `.git/modules/` directory recursively to find all submodule `info/` dirs. + * Handles nested submodules (modules within modules). + * Returns absolute paths of `info/` directories. + */ +export function findGitModuleInfoDirs(dotGitDir: string): string[] { + const modulesDir = path.join(dotGitDir, 'modules') + if (!fs.existsSync(modulesDir)) return [] + + const results: string[] = [] + + function walk(dir: string): void { + let entries: fs.Dirent[] + try { + const raw = fs.readdirSync(dir, {withFileTypes: true}) + if (!Array.isArray(raw)) return + entries = raw + } + catch { return } + + const hasInfo = entries.some(e => e.name === 'info' && e.isDirectory()) + if (hasInfo) results.push(path.join(dir, 'info')) + + const nestedModules = entries.find(e => e.name === 'modules' && e.isDirectory()) + 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)) + } + } + + let topEntries: fs.Dirent[] + try { + const raw = fs.readdirSync(modulesDir, {withFileTypes: true}) + if (!Array.isArray(raw)) return results + topEntries = raw + } + catch { return results } + + for (const entry of topEntries) { + if (entry.isDirectory()) walk(path.join(modulesDir, entry.name)) + } + + return results +} diff --git a/cli/src/plugins/plugin-output-shared/utils/index.ts b/cli/src/plugins/plugin-output-shared/utils/index.ts new file mode 100644 index 00000000..0fc6db46 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/index.ts @@ -0,0 +1,25 @@ +export { + filterCommandsByProjectConfig +} from './commandFilter' +export { + findAllGitRepos, + findGitModuleInfoDirs, + resolveGitInfoDir +} from './gitUtils' +export { + applySubSeriesGlobPrefix, + filterRulesByProjectConfig, + getGlobalRules, + getProjectRules +} from './ruleFilter' +export { + matchesSeries, + resolveEffectiveIncludeSeries, + resolveSubSeries +} from './seriesFilter' +export { + filterSkillsByProjectConfig +} from './skillFilter' +export { + filterSubAgentsByProjectConfig +} from './subAgentFilter' diff --git a/cli/src/plugins/plugin-output-shared/utils/pathNormalization.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/pathNormalization.property.test.ts new file mode 100644 index 00000000..514700d3 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/pathNormalization.property.test.ts @@ -0,0 +1,57 @@ +/** Property 4: SubSeries path normalization idempotence. Validates: Requirement 5.4 */ +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' + +import {normalizeSubdirPath} from './ruleFilter' + +const pathArb = fc.stringMatching(/^[./_a-z0-9-]{0,40}$/) + +const subdirPathArb = fc.oneof( + fc.constant('./foo/'), + fc.constant('foo/'), + fc.constant('./foo'), + fc.constant('foo'), + fc.constant(''), + fc.constant('./'), + fc.constant('.//foo//'), + fc.constant('././foo///'), + fc.constant('./a/b/c/'), + pathArb +) + +describe('property 4: subSeries path normalization idempotence', () => { + it('normalize(normalize(p)) === normalize(p) for arbitrary path strings', () => { // **Validates: Requirement 5.4** + fc.assert( + fc.property(subdirPathArb, p => { + const once = normalizeSubdirPath(p) + const twice = normalizeSubdirPath(once) + expect(twice).toBe(once) + }), + {numRuns: 200} + ) + }) + + it('result never starts with ./', () => { // **Validates: Requirement 5.4** + fc.assert( + fc.property(subdirPathArb, p => { + const result = normalizeSubdirPath(p) + expect(result.startsWith('./')).toBe(false) + }), + {numRuns: 200} + ) + }) + + it('result never ends with /', () => { // **Validates: Requirement 5.4** + fc.assert( + fc.property(subdirPathArb, p => { + const result = normalizeSubdirPath(p) + expect(result.endsWith('/')).toBe(false) + }), + {numRuns: 200} + ) + }) + + it('empty string stays empty', () => { // **Validates: Requirement 5.4** + expect(normalizeSubdirPath('')).toBe('') + }) +}) diff --git a/cli/src/plugins/plugin-output-shared/utils/ruleFilter.ts b/cli/src/plugins/plugin-output-shared/utils/ruleFilter.ts new file mode 100644 index 00000000..117e2ea0 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/ruleFilter.ts @@ -0,0 +1,105 @@ +import type {RulePrompt} from '@truenine/plugin-shared' +import type {Project, ProjectConfig} from '@truenine/plugin-shared/types' +import {matchesSeries, resolveEffectiveIncludeSeries, resolveSubSeries} from './seriesFilter' + +export function normalizeSubdirPath(subdir: string): string { + let normalized = subdir.replaceAll(/\.\/+/g, '') + normalized = normalized.replaceAll(/\/+$/g, '') + return normalized +} + +function smartConcatGlob(prefix: string, glob: string): string { + if (glob.startsWith('**/')) return `${prefix}/${glob}` + if (glob.startsWith('*')) return `${prefix}/**/${glob}` + return `${prefix}/${glob}` +} + +function extractPrefixAndBaseGlob(glob: string, prefixes: readonly string[]): {prefix: string | null, baseGlob: string} { + for (const prefix of prefixes) { + const normalizedPrefix = prefix.replaceAll(/\/+$/g, '') + const patterns = [ + {prefix: normalizedPrefix, pattern: `${normalizedPrefix}/`}, + {prefix: normalizedPrefix, pattern: `${normalizedPrefix}\\`} + ] + for (const {prefix: p, pattern} of patterns) { + if (glob.startsWith(pattern)) return {prefix: p, baseGlob: glob.slice(pattern.length)} + } + if (glob === normalizedPrefix) return {prefix: normalizedPrefix, baseGlob: '**/*'} + } + return {prefix: null, baseGlob: glob} +} + +export function applySubSeriesGlobPrefix( + rules: readonly RulePrompt[], + projectConfig: ProjectConfig | undefined +): readonly RulePrompt[] { + const subSeries = resolveSubSeries(projectConfig?.subSeries, projectConfig?.rules?.subSeries) + if (Object.keys(subSeries).length === 0) return rules + + const normalizedSubSeries: Record = {} + for (const [subdir, seriNames] of Object.entries(subSeries)) { + const normalizedSubdir = normalizeSubdirPath(subdir) + normalizedSubSeries[normalizedSubdir] = seriNames + } + + const allPrefixes = Object.keys(normalizedSubSeries) + + return rules.map(rule => { + if (rule.seriName == null) return rule + + const matchedPrefixes: string[] = [] + for (const [subdir, seriNames] of Object.entries(normalizedSubSeries)) { + const matched = Array.isArray(rule.seriName) + ? rule.seriName.some(name => seriNames.includes(name)) + : seriNames.includes(rule.seriName) + if (matched) matchedPrefixes.push(subdir) + } + + if (matchedPrefixes.length === 0) return rule + + const newGlobs: string[] = [] + for (const originalGlob of rule.globs) { + const {prefix: existingPrefix, baseGlob} = extractPrefixAndBaseGlob(originalGlob, allPrefixes) + + if (existingPrefix != null) newGlobs.push(originalGlob) + + for (const prefix of matchedPrefixes) { + if (prefix === existingPrefix) continue + const newGlob = smartConcatGlob(prefix, baseGlob) + if (!newGlobs.includes(newGlob)) newGlobs.push(newGlob) + } + } + + return { + ...rule, + globs: newGlobs + } + }) +} + +export function filterRulesByProjectConfig( + rules: readonly RulePrompt[], + projectConfig: ProjectConfig | undefined +): readonly RulePrompt[] { + const effectiveSeries = resolveEffectiveIncludeSeries(projectConfig?.includeSeries, projectConfig?.rules?.includeSeries) + return rules.filter(rule => matchesSeries(rule.seriName, effectiveSeries)) +} + +function normalizeRuleScope(rule: RulePrompt): string { + return rule.scope ?? 'project' +} + +/** + * Returns project-scoped rules for a given project, with sub-series glob prefix applied. + */ +export function getProjectRules(rules: readonly RulePrompt[], project: Project): readonly RulePrompt[] { + const projectRules = rules.filter(r => normalizeRuleScope(r) === 'project') + return applySubSeriesGlobPrefix(filterRulesByProjectConfig(projectRules, project.projectConfig), project.projectConfig) +} + +/** + * Returns global-scoped rules from the given rule list. + */ +export function getGlobalRules(rules: readonly RulePrompt[]): readonly RulePrompt[] { + return rules.filter(r => normalizeRuleScope(r) === 'global') +} diff --git a/cli/src/plugins/plugin-output-shared/utils/seriesFilter.napi-equivalence.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/seriesFilter.napi-equivalence.property.test.ts new file mode 100644 index 00000000..72df74a6 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/seriesFilter.napi-equivalence.property.test.ts @@ -0,0 +1,107 @@ +/** Property 5: NAPI and TypeScript behavioral equivalence. Validates: Requirement 6.4 */ +import * as napiConfig from '@truenine/config' +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' + +const napiAvailable = typeof napiConfig.matchesSeries === 'function' + && typeof napiConfig.resolveEffectiveIncludeSeries === 'function' + && typeof napiConfig.resolveSubSeries === 'function' + +function resolveEffectiveIncludeSeriesTS(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] { + if (topLevel == null && typeSpecific == null) return [] + return [...new Set([...topLevel ?? [], ...typeSpecific ?? []])] +} + +function matchesSeriesTS(seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]): boolean { + if (seriName == null) return true + if (effectiveIncludeSeries.length === 0) return true + if (typeof seriName === 'string') return effectiveIncludeSeries.includes(seriName) + return seriName.some(name => effectiveIncludeSeries.includes(name)) +} + +function resolveSubSeriesTS( + topLevel?: Readonly>, + typeSpecific?: Readonly> +): Record { + if (topLevel == null && typeSpecific == null) return {} + const merged: Record = {} + for (const [key, values] of Object.entries(topLevel ?? {})) merged[key] = [...values] + for (const [key, values] of Object.entries(typeSpecific ?? {})) { + merged[key] = Object.hasOwn(merged, key) ? [...new Set([...merged[key]!, ...values])] : [...values] + } + return merged +} + +const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) + +const optionalSeriesArb = fc.option(fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), {nil: void 0}) + +const seriNameArb: fc.Arbitrary = fc.oneof( + fc.constant(null), + fc.constant(void 0), + seriesNameArb, + fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) +) + +const subSeriesRecordArb = fc.option( + fc.dictionary(seriesNameArb, fc.array(seriesNameArb, {minLength: 0, maxLength: 5})), + {nil: void 0} +) + +function sortedArray(arr: readonly string[]): string[] { + return [...arr].sort() +} + +function sortedRecord(rec: Readonly>): Record { + const out: Record = {} + for (const key of Object.keys(rec).sort()) out[key] = [...new Set(rec[key])].sort() + return out +} + +describe.skipIf(!napiAvailable)('property 5: NAPI and TypeScript behavioral equivalence', () => { + it('resolveEffectiveIncludeSeries: NAPI and TS produce same set', () => { // **Validates: Requirement 6.4** + fc.assert( + fc.property( + optionalSeriesArb, + optionalSeriesArb, + (topLevel, typeSpecific) => { + const napiResult = napiConfig.resolveEffectiveIncludeSeries(topLevel, typeSpecific) + const tsResult = resolveEffectiveIncludeSeriesTS(topLevel, typeSpecific) + expect(sortedArray(napiResult)).toEqual(sortedArray(tsResult)) + } + ), + {numRuns: 200} + ) + }) + + it('matchesSeries: NAPI and TS produce identical boolean', () => { // **Validates: Requirement 6.4** + fc.assert( + fc.property( + seriNameArb, + fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), + (seriName, list) => { + const napiResult = napiConfig.matchesSeries(seriName, list) + const tsResult = matchesSeriesTS(seriName, list) + expect(napiResult).toBe(tsResult) + } + ), + {numRuns: 200} + ) + }) + + it('resolveSubSeries: NAPI and TS produce same merged record', () => { // **Validates: Requirement 6.4** + fc.assert( + fc.property( + subSeriesRecordArb, + subSeriesRecordArb, + (topLevel, typeSpecific) => { + const napiResult = napiConfig.resolveSubSeries(topLevel, typeSpecific) + const tsResult = resolveSubSeriesTS(topLevel, typeSpecific) + expect(sortedRecord(napiResult)).toEqual(sortedRecord(tsResult)) + } + ), + {numRuns: 200} + ) + }) +}) diff --git a/cli/src/plugins/plugin-output-shared/utils/seriesFilter.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/seriesFilter.property.test.ts new file mode 100644 index 00000000..57fe292d --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/seriesFilter.property.test.ts @@ -0,0 +1,154 @@ +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' + +import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' + +/** Property 1: Effective IncludeSeries is the set union. Validates: Requirements 3.1, 3.2, 3.3, 3.4 */ +describe('resolveEffectiveIncludeSeries property tests', () => { + const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^[\w-]+$/.test(s)) + + const optionalSeriesArb = fc.option(fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), {nil: void 0}) + + it('property 1: result is the set union of both inputs, undefined treated as empty', () => { // **Validates: Requirements 3.1, 3.2, 3.3, 3.4** + fc.assert( + fc.property( + optionalSeriesArb, + optionalSeriesArb, + (topLevel, typeSpecific) => { + const result = resolveEffectiveIncludeSeries(topLevel, typeSpecific) + const expectedUnion = new Set([...topLevel ?? [], ...typeSpecific ?? []]) + + for (const item of result) expect(expectedUnion.has(item)).toBe(true) // every result element comes from an input + for (const item of expectedUnion) expect(result).toContain(item) // every input element is in the result + expect(result.length).toBe(new Set(result).size) // no duplicates + } + ), + {numRuns: 200} + ) + }) + + it('property 1: both undefined yields empty array', () => { // **Validates: Requirement 3.4** + const result = resolveEffectiveIncludeSeries(void 0, void 0) + expect(result).toEqual([]) + }) + + it('property 1: only top-level defined yields top-level (deduplicated)', () => { // **Validates: Requirement 3.2** + fc.assert( + fc.property( + fc.array(seriesNameArb, {minLength: 1, maxLength: 10}), + topLevel => { + const result = resolveEffectiveIncludeSeries(topLevel, void 0) + const expected = [...new Set(topLevel)] + expect(result).toEqual(expected) + } + ), + {numRuns: 200} + ) + }) + + it('property 1: only type-specific defined yields type-specific (deduplicated)', () => { // **Validates: Requirement 3.3** + fc.assert( + fc.property( + fc.array(seriesNameArb, {minLength: 1, maxLength: 10}), + typeSpecific => { + const result = resolveEffectiveIncludeSeries(void 0, typeSpecific) + const expected = [...new Set(typeSpecific)] + expect(result).toEqual(expected) + } + ), + {numRuns: 200} + ) + }) +}) + +/** Property 2: Series matching correctness. Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5 */ +describe('matchesSeries property tests', () => { + const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^[\w-]+$/.test(s)) + + const nonEmptySeriesListArb = fc.array(seriesNameArb, {minLength: 1, maxLength: 10}) + .map(arr => [...new Set(arr)]) + .filter(arr => arr.length > 0) + + const seriNameArb: fc.Arbitrary = fc.oneof( + fc.constant(null), + fc.constant(void 0), + seriesNameArb, + fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) + ) + + it('property 2: null/undefined seriName is always included regardless of list', () => { // **Validates: Requirements 4.1** + fc.assert( + fc.property( + fc.oneof(fc.constant(null), fc.constant(void 0)), + nonEmptySeriesListArb, + (seriName, list) => { expect(matchesSeries(seriName, list)).toBe(true) } + ), + {numRuns: 200} + ) + }) + + it('property 2: empty effectiveIncludeSeries includes all seriName values', () => { // **Validates: Requirements 4.4** + fc.assert( + fc.property( + seriNameArb, + seriName => { expect(matchesSeries(seriName, [])).toBe(true) } + ), + {numRuns: 200} + ) + }) + + it('property 2: string seriName included iff it is a member of the list', () => { // **Validates: Requirements 4.2, 4.5** + fc.assert( + fc.property( + seriesNameArb, + nonEmptySeriesListArb, + (seriName, list) => { + const result = matchesSeries(seriName, list) + const expected = list.includes(seriName) + expect(result).toBe(expected) + } + ), + {numRuns: 200} + ) + }) + + it('property 2: array seriName included iff intersection with list is non-empty', () => { // **Validates: Requirements 4.3** + fc.assert( + fc.property( + fc.array(seriesNameArb, {minLength: 0, maxLength: 5}), + nonEmptySeriesListArb, + (seriNameArr, list) => { + const result = matchesSeries(seriNameArr, list) + const hasIntersection = seriNameArr.some(n => list.includes(n)) + expect(result).toBe(hasIntersection) + } + ), + {numRuns: 200} + ) + }) + + it('property 2: combined — all seriName variants obey spec rules', () => { // **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5** + fc.assert( + fc.property( + seriNameArb, + fc.oneof(fc.constant([] as string[]), nonEmptySeriesListArb), + (seriName, list) => { + const result = matchesSeries(seriName, list) + + if (seriName == null) { + expect(result).toBe(true) // 4.1 + } else if (list.length === 0) { + expect(result).toBe(true) // 4.4 + } else if (typeof seriName === 'string') { + expect(result).toBe(list.includes(seriName)) // 4.2, 4.5 + } else { + expect(result).toBe(seriName.some(n => list.includes(n))) // 4.3 + } + } + ), + {numRuns: 300} + ) + }) +}) diff --git a/cli/src/plugins/plugin-output-shared/utils/seriesFilter.ts b/cli/src/plugins/plugin-output-shared/utils/seriesFilter.ts new file mode 100644 index 00000000..d82f4922 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/seriesFilter.ts @@ -0,0 +1,61 @@ +/** Core series filtering helpers. Delegates to Rust NAPI via `@truenine/config` when available, falls back to pure-TS implementations otherwise. */ +import {createRequire} from 'node:module' + +function resolveEffectiveIncludeSeriesTS(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] { + if (topLevel == null && typeSpecific == null) return [] + return [...new Set([...topLevel ?? [], ...typeSpecific ?? []])] +} + +function matchesSeriesTS(seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]): boolean { + if (seriName == null) return true + if (effectiveIncludeSeries.length === 0) return true + if (typeof seriName === 'string') return effectiveIncludeSeries.includes(seriName) + return seriName.some(name => effectiveIncludeSeries.includes(name)) +} + +function resolveSubSeriesTS( + topLevel?: Readonly>, + typeSpecific?: Readonly> +): Record { + if (topLevel == null && typeSpecific == null) return {} + const merged: Record = {} + for (const [key, values] of Object.entries(topLevel ?? {})) merged[key] = [...values] + for (const [key, values] of Object.entries(typeSpecific ?? {})) { + merged[key] = Object.hasOwn(merged, key) ? [...new Set([...merged[key]!, ...values])] : [...values] + } + return merged +} + +interface SeriesFilterFns { + resolveEffectiveIncludeSeries: typeof resolveEffectiveIncludeSeriesTS + matchesSeries: typeof matchesSeriesTS + resolveSubSeries: typeof resolveSubSeriesTS +} + +function tryLoadNapi(): SeriesFilterFns | undefined { + try { + const _require = createRequire(import.meta.url) + const napi = _require('@truenine/config') as SeriesFilterFns + if (typeof napi.matchesSeries === 'function' + && typeof napi.resolveEffectiveIncludeSeries === 'function' + && typeof napi.resolveSubSeries === 'function') return napi + } + catch { /* NAPI unavailable — pure-TS fallback will be used */ } + return void 0 +} + +const { + resolveEffectiveIncludeSeries, + matchesSeries, + resolveSubSeries +}: SeriesFilterFns = tryLoadNapi() ?? { + resolveEffectiveIncludeSeries: resolveEffectiveIncludeSeriesTS, + matchesSeries: matchesSeriesTS, + resolveSubSeries: resolveSubSeriesTS +} + +export { + matchesSeries, + resolveEffectiveIncludeSeries, + resolveSubSeries +} diff --git a/cli/src/plugins/plugin-output-shared/utils/skillFilter.ts b/cli/src/plugins/plugin-output-shared/utils/skillFilter.ts new file mode 100644 index 00000000..6f09a457 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/skillFilter.ts @@ -0,0 +1,11 @@ +import type {SkillPrompt} from '@truenine/plugin-shared' +import type {ProjectConfig} from '@truenine/plugin-shared/types' +import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' + +export function filterSkillsByProjectConfig( + skills: readonly SkillPrompt[], + projectConfig: ProjectConfig | undefined +): readonly SkillPrompt[] { + const effectiveSeries = resolveEffectiveIncludeSeries(projectConfig?.includeSeries, projectConfig?.skills?.includeSeries) + return skills.filter(skill => matchesSeries(skill.seriName, effectiveSeries)) +} diff --git a/cli/src/plugins/plugin-output-shared/utils/subAgentFilter.ts b/cli/src/plugins/plugin-output-shared/utils/subAgentFilter.ts new file mode 100644 index 00000000..204e5223 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/subAgentFilter.ts @@ -0,0 +1,11 @@ +import type {SubAgentPrompt} from '@truenine/plugin-shared' +import type {ProjectConfig} from '@truenine/plugin-shared/types' +import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' + +export function filterSubAgentsByProjectConfig( + subAgents: readonly SubAgentPrompt[], + projectConfig: ProjectConfig | undefined +): readonly SubAgentPrompt[] { + const effectiveSeries = resolveEffectiveIncludeSeries(projectConfig?.includeSeries, projectConfig?.subAgents?.includeSeries) + return subAgents.filter(subAgent => matchesSeries(subAgent.seriName, effectiveSeries)) +} diff --git a/cli/src/plugins/plugin-output-shared/utils/subSeriesGlobExpansion.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/subSeriesGlobExpansion.property.test.ts new file mode 100644 index 00000000..a9fadb1d --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/subSeriesGlobExpansion.property.test.ts @@ -0,0 +1,196 @@ +/** Property 3: SubSeries glob expansion. Validates: Requirements 5.1, 5.2, 5.3 */ +import type {RulePrompt} from '@truenine/plugin-shared' +import type {ProjectConfig} from '@truenine/plugin-shared/types' +import {FilePathKind, PromptKind} from '@truenine/plugin-shared' +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' + +import {applySubSeriesGlobPrefix} from './ruleFilter' + +const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) + +const seriNameArb: fc.Arbitrary = fc.oneof( + fc.constant(null), + fc.constant(void 0), + seriesNameArb, + fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) +) + +const globGen = fc.stringMatching(/^\*\*\/\*\.[a-z]{1,5}$/) +const globArrayGen = fc.array(globGen, {minLength: 1, maxLength: 5}) +const subdirGen = fc.stringMatching(/^[a-z][a-z0-9/-]{0,30}$/) + .filter(s => !s.endsWith('/') && !s.includes('//')) + +function createMockRulePrompt(seriName: string | string[] | null | undefined, globs: readonly string[] = ['**/*.ts']): RulePrompt { + const content = '# Rule body' + return { + type: PromptKind.Rule, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: {pathKind: FilePathKind.Relative, path: '.', basePath: '', getDirectoryName: () => '.', getAbsolutePath: () => '.'}, + markdownContents: [], + yamlFrontMatter: {description: 'Test rule', globs: [...globs]}, + series: 'test', + ruleName: 'test-rule', + globs: [...globs], + scope: 'project', + seriName + } as unknown as RulePrompt +} + +describe('property 3: subSeries glob expansion', () => { + it('rules without seriName have unchanged globs', () => { // **Validates: Requirements 5.2** + fc.assert( + fc.property( + globArrayGen, + subdirGen, + fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), + (globs, subdir, seriNames) => { + const rule = createMockRulePrompt(null, globs) + const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} + const result = applySubSeriesGlobPrefix([rule], config) + expect(result).toHaveLength(1) + expect(result[0]!.globs).toEqual(globs) + } + ), + {numRuns: 200} + ) + }) + + it('rules with undefined seriName have unchanged globs', () => { // **Validates: Requirements 5.2** + fc.assert( + fc.property( + globArrayGen, + subdirGen, + fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), + (globs, subdir, seriNames) => { + const rule = createMockRulePrompt(void 0, globs) + const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} + const result = applySubSeriesGlobPrefix([rule], config) + expect(result).toHaveLength(1) + expect(result[0]!.globs).toEqual(globs) + } + ), + {numRuns: 200} + ) + }) + + it('string seriName matching subSeries expands globs with subdir prefix', () => { // **Validates: Requirements 5.1** + fc.assert( + fc.property( + seriesNameArb, + globArrayGen, + subdirGen, + (seriName, globs, subdir) => { + const rule = createMockRulePrompt(seriName, globs) + const config: ProjectConfig = {subSeries: {[subdir]: [seriName]}} + const result = applySubSeriesGlobPrefix([rule], config) + expect(result).toHaveLength(1) + const resultGlobs = result[0]!.globs + for (const g of resultGlobs) expect(g).toContain(subdir) // every expanded glob contains the subdir prefix + } + ), + {numRuns: 200} + ) + }) + + it('array seriName matching subSeries expands globs for all matching subdirs', () => { // **Validates: Requirements 5.1, 5.3** + fc.assert( + fc.property( + seriesNameArb, + globArrayGen, + fc.array(subdirGen, {minLength: 2, maxLength: 4}).filter(arr => new Set(arr).size === arr.length), + (seriName, globs, subdirs) => { + const rule = createMockRulePrompt([seriName], globs) + const subSeries: Record = {} // each subdir maps to the same seriName + for (const sd of subdirs) subSeries[sd] = [seriName] + const config: ProjectConfig = {subSeries} + const result = applySubSeriesGlobPrefix([rule], config) + expect(result).toHaveLength(1) + const resultGlobs = result[0]!.globs + for (const sd of subdirs) expect(resultGlobs.some(g => g.includes(sd))).toBe(true) // every subdir appears in at least one expanded glob + } + ), + {numRuns: 200} + ) + }) + + it('non-matching seriName leaves globs unchanged', () => { // **Validates: Requirements 5.2** + fc.assert( + fc.property( + seriesNameArb, + seriesNameArb, + globArrayGen, + subdirGen, + (ruleSeriName, subSeriesSeriName, globs, subdir) => { + fc.pre(ruleSeriName !== subSeriesSeriName) + const rule = createMockRulePrompt(ruleSeriName, globs) + const config: ProjectConfig = {subSeries: {[subdir]: [subSeriesSeriName]}} + const result = applySubSeriesGlobPrefix([rule], config) + expect(result).toHaveLength(1) + expect(result[0]!.globs).toEqual(globs) + } + ), + {numRuns: 200} + ) + }) + + it('rule count is preserved', () => { // **Validates: Requirements 5.1, 5.2, 5.3** + fc.assert( + fc.property( + fc.array(fc.tuple(seriNameArb, globArrayGen), {minLength: 0, maxLength: 10}), + subdirGen, + fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), + (ruleSpecs, subdir, seriNames) => { + const rules = ruleSpecs.map(([sn, gl]) => createMockRulePrompt(sn, gl)) + const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} + const result = applySubSeriesGlobPrefix(rules, config) + expect(result).toHaveLength(rules.length) + } + ), + {numRuns: 200} + ) + }) + + it('deterministic: same input produces same output', () => { // **Validates: Requirements 5.1, 5.2, 5.3** + fc.assert( + fc.property( + seriNameArb, + globArrayGen, + subdirGen, + fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), + (seriName, globs, subdir, seriNames) => { + const rules = [createMockRulePrompt(seriName, globs)] + const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} + const result1 = applySubSeriesGlobPrefix(rules, config) + const result2 = applySubSeriesGlobPrefix(rules, config) + expect(result1).toEqual(result2) + } + ), + {numRuns: 200} + ) + }) + + it('at least one glob per matched subdir when matched', () => { // **Validates: Requirements 5.1, 5.3** + fc.assert( + fc.property( + seriesNameArb, + globArrayGen, + fc.array(subdirGen, {minLength: 1, maxLength: 4}).filter(arr => new Set(arr).size === arr.length), + (seriName, globs, subdirs) => { + const rule = createMockRulePrompt(seriName, globs) + const subSeries: Record = {} + for (const sd of subdirs) subSeries[sd] = [seriName] + const config: ProjectConfig = {subSeries} + const result = applySubSeriesGlobPrefix([rule], config) + expect(result).toHaveLength(1) + const resultGlobs = result[0]!.globs + expect(resultGlobs.length).toBeGreaterThanOrEqual(subdirs.length) // at least as many globs as unique matched subdirs + } + ), + {numRuns: 200} + ) + }) +}) diff --git a/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts new file mode 100644 index 00000000..6bb0ddc5 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts @@ -0,0 +1,119 @@ +/** Property 6: Type-specific filters use correct config sections. Validates: Requirements 7.1, 7.2, 7.3, 7.4 */ +import type {FastCommandPrompt, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' +import type {ProjectConfig} from '@truenine/plugin-shared/types' +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' + +import {filterCommandsByProjectConfig} from './commandFilter' +import {filterRulesByProjectConfig} from './ruleFilter' +import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' +import {filterSkillsByProjectConfig} from './skillFilter' +import {filterSubAgentsByProjectConfig} from './subAgentFilter' + +const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) + +const seriNameArb: fc.Arbitrary = fc.oneof( + fc.constant(null), + fc.constant(void 0), + seriesNameArb, + fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) +) + +const optionalSeriesArb = fc.option(fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), {nil: void 0}) + +const typeSeriesConfigArb = fc.record({includeSeries: optionalSeriesArb}) + +const projectConfigArb: fc.Arbitrary = fc.record({ + includeSeries: optionalSeriesArb, + rules: fc.option(typeSeriesConfigArb, {nil: void 0}), + skills: fc.option(typeSeriesConfigArb, {nil: void 0}), + subAgents: fc.option(typeSeriesConfigArb, {nil: void 0}), + commands: fc.option(typeSeriesConfigArb, {nil: void 0}) +}) + +function makeSkill(seriName: string | string[] | null | undefined): SkillPrompt { + return {seriName} as unknown as SkillPrompt +} + +function makeRule(seriName: string | string[] | null | undefined): RulePrompt { + return {seriName, globs: [], scope: 'project', series: '', ruleName: '', type: 'Rule'} as unknown as RulePrompt +} + +function makeSubAgent(seriName: string | string[] | null | undefined): SubAgentPrompt { + return {seriName, agentName: '', type: 'SubAgent'} as unknown as SubAgentPrompt +} + +function makeCommand(seriName: string | string[] | null | undefined): FastCommandPrompt { + return {seriName, commandName: '', type: 'FastCommand'} as unknown as FastCommandPrompt +} + +describe('property 6: type-specific filters use correct config sections', () => { + it('filterSkillsByProjectConfig matches manual filtering with skills includeSeries', () => { // **Validates: Requirement 7.1** + fc.assert( + fc.property( + projectConfigArb, + fc.array(seriNameArb, {minLength: 0, maxLength: 10}), + (config, seriNames) => { + const skills = seriNames.map(makeSkill) + const filtered = filterSkillsByProjectConfig(skills, config) + const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.skills?.includeSeries) + const expected = skills.filter(s => matchesSeries(s.seriName, effectiveSeries)) + expect(filtered).toEqual(expected) + } + ), + {numRuns: 200} + ) + }) + + it('filterRulesByProjectConfig matches manual filtering with rules includeSeries', () => { // **Validates: Requirement 7.2** + fc.assert( + fc.property( + projectConfigArb, + fc.array(seriNameArb, {minLength: 0, maxLength: 10}), + (config, seriNames) => { + const rules = seriNames.map(makeRule) + const filtered = filterRulesByProjectConfig(rules, config) + const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.rules?.includeSeries) + const expected = rules.filter(r => matchesSeries(r.seriName, effectiveSeries)) + expect(filtered).toEqual(expected) + } + ), + {numRuns: 200} + ) + }) + + it('filterSubAgentsByProjectConfig matches manual filtering with subAgents includeSeries', () => { // **Validates: Requirement 7.3** + fc.assert( + fc.property( + projectConfigArb, + fc.array(seriNameArb, {minLength: 0, maxLength: 10}), + (config, seriNames) => { + const subAgents = seriNames.map(makeSubAgent) + const filtered = filterSubAgentsByProjectConfig(subAgents, config) + const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.subAgents?.includeSeries) + const expected = subAgents.filter(sa => matchesSeries(sa.seriName, effectiveSeries)) + expect(filtered).toEqual(expected) + } + ), + {numRuns: 200} + ) + }) + + it('filterCommandsByProjectConfig matches manual filtering with commands includeSeries', () => { // **Validates: Requirement 7.4** + fc.assert( + fc.property( + projectConfigArb, + fc.array(seriNameArb, {minLength: 0, maxLength: 10}), + (config, seriNames) => { + const commands = seriNames.map(makeCommand) + const filtered = filterCommandsByProjectConfig(commands, config) + const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.commands?.includeSeries) + const expected = commands.filter(c => matchesSeries(c.seriName, effectiveSeries)) + expect(filtered).toEqual(expected) + } + ), + {numRuns: 200} + ) + }) +}) diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.frontmatter.test.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.frontmatter.test.ts new file mode 100644 index 00000000..06b4ed2e --- /dev/null +++ b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.frontmatter.test.ts @@ -0,0 +1,63 @@ +import {beforeEach, describe, expect, it} from 'vitest' +import {QoderIDEPluginOutputPlugin} from './QoderIDEPluginOutputPlugin' + +describe('qoderidepluginoutputplugin front matter', () => { + let plugin: QoderIDEPluginOutputPlugin + + beforeEach(() => plugin = new QoderIDEPluginOutputPlugin()) + + describe('buildAlwaysRuleContent', () => { + it('should include type: user_command in front matter', () => { + const content = 'Test always rule content' + const result = (plugin as any).buildAlwaysRuleContent(content) + + expect(result).toContain('type: user_command') + expect(result).toContain('trigger: always_on') + expect(result).toContain(content) + }) + }) + + describe('buildGlobRuleContent', () => { + it('should include type: user_command in front matter', () => { + const mockChild = { + content: 'Test glob rule content', + workingChildDirectoryPath: {path: 'src/utils'} + } + + const result = (plugin as any).buildGlobRuleContent(mockChild) + + expect(result).toContain('type: user_command') + expect(result).toContain('trigger: glob') + expect(result).toContain('glob: src/utils/**') + expect(result).toContain('Test glob rule content') + }) + }) + + describe('buildFastCommandFrontMatter', () => { + it('should include type: user_command in fast command front matter', () => { + const mockCmd = { + yamlFrontMatter: { + description: 'Test fast command', + argumentHint: 'test args', + allowTools: ['tool1', 'tool2'] + } + } + + const result = (plugin as any).buildFastCommandFrontMatter(mockCmd) + + expect(result.type).toBe('user_command') + expect(result.description).toBe('Test fast command') + expect(result.argumentHint).toBe('test args') + expect(result.allowTools).toEqual(['tool1', 'tool2']) + }) + + it('should handle fast command without yamlFrontMatter', () => { + const mockCmd = {} + + const result = (plugin as any).buildFastCommandFrontMatter(mockCmd) + + expect(result.type).toBe('user_command') + expect(result.description).toBe('Fast command') + }) + }) +}) diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts new file mode 100644 index 00000000..cef861dc --- /dev/null +++ b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts @@ -0,0 +1,118 @@ +import type {OutputWriteContext} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {QoderIDEPluginOutputPlugin} from './QoderIDEPluginOutputPlugin' + +class TestableQoderIDEPlugin extends QoderIDEPluginOutputPlugin { + private mockHomeDir: string | null = null + public setMockHomeDir(dir: string | null): void { this.mockHomeDir = dir } + protected override getHomeDir(): string { return this.mockHomeDir ?? super.getHomeDir() } +} + +function createMockWriteContext(tempDir: string, rules: unknown[], projects: unknown[]): OutputWriteContext { + return { + collectedInputContext: { + workspace: { + projects: projects as never, + directory: {pathKind: 1, path: tempDir, basePath: tempDir, getDirectoryName: () => 'workspace', getAbsolutePath: () => tempDir} + }, + ideConfigFiles: [], + rules: rules as never, + fastCommands: [], + skills: [], + globalMemory: void 0, + aiAgentIgnoreConfigFiles: [] + }, + dryRun: false, + logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as never, + fs, + path, + glob: vi.fn() as never + } +} + +describe('qoderIDEPluginOutputPlugin - projectConfig filtering', () => { + let tempDir: string, plugin: TestableQoderIDEPlugin + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-proj-config-test-')) + plugin = new TestableQoderIDEPlugin() + plugin.setMockHomeDir(tempDir) + }) + + afterEach(() => { + try { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch {} + }) + + function ruleFile(projectPath: string, series: string, ruleName: string): string { + return path.join(tempDir, projectPath, '.qoder', 'rules', `rule-${series}-${ruleName}.md`) + } + + describe('writeProjectOutputs', () => { + it('should write all project rules when no projectConfig', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [createMockProject('proj1', tempDir, 'proj1')])) + + expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(true) + expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(true) + }) + + it('should only write rules matching include filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ])) + + expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(true) + expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(false) + }) + + it('should not write rules not matching includeSeries filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) + ])) + + expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(false) + expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(true) + }) + + it('should write rules without seriName regardless of include filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', void 0, 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ])) + + expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(true) + expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(false) + }) + + it('should write expanded glob when subSeries matches seriName', async () => { + const rules = [createMockRulePrompt('test', 'rule1', 'uniapp', 'project')] + await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ + createMockProject('proj1', tempDir, 'proj1', {rules: {subSeries: {applet: ['uniapp']}}}) + ])) + + const content = fs.readFileSync(ruleFile('proj1', 'test', 'rule1'), 'utf8') + expect(content).toContain('applet/') + }) + }) +}) diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.test.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.test.ts new file mode 100644 index 00000000..511e9751 --- /dev/null +++ b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.test.ts @@ -0,0 +1,485 @@ +import type { + CollectedInputContext, + FastCommandPrompt, + GlobalMemoryPrompt, + OutputPluginContext, + OutputWriteContext, + ProjectChildrenMemoryPrompt, + ProjectRootMemoryPrompt, + RelativePath, + SkillPrompt +} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as path from 'node:path' +import {FilePathKind, PromptKind} from '@truenine/plugin-shared' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {QoderIDEPluginOutputPlugin} from './QoderIDEPluginOutputPlugin' + +vi.mock('node:fs') + +const MOCK_WORKSPACE_DIR = '/workspace/test' + +class TestableQoderIDEPluginOutputPlugin extends QoderIDEPluginOutputPlugin { + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } +} + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { + return { + pathKind: FilePathKind.Relative, + path: pathStr, + basePath, + getDirectoryName: () => path.basename(pathStr), + getAbsolutePath: () => path.join(basePath, pathStr) + } +} + +function createMockRootMemoryPrompt(content: string, basePath: string): ProjectRootMemoryPrompt { + return { + type: PromptKind.ProjectRootMemory, + content, + dir: createMockRelativePath('.', basePath), + markdownContents: [], + length: content.length, + filePathKind: FilePathKind.Relative + } as ProjectRootMemoryPrompt +} + +function createMockChildMemoryPrompt( + content: string, + projectPath: string, + basePath: string, + workingPath?: string +): ProjectChildrenMemoryPrompt { + const childPath = workingPath ?? projectPath + return { + type: PromptKind.ProjectChildrenMemory, + dir: createMockRelativePath(projectPath, basePath), + workingChildDirectoryPath: createMockRelativePath(childPath, basePath), + content, + markdownContents: [], + length: content.length, + filePathKind: FilePathKind.Relative + } as ProjectChildrenMemoryPrompt +} + +function createMockGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt { + return { + type: PromptKind.GlobalMemory, + content, + dir: createMockRelativePath('.', basePath), + markdownContents: [], + length: content.length, + filePathKind: FilePathKind.Relative, + parentDirectoryPath: { + type: 'UserHome', + directory: createMockRelativePath('.qoder', basePath) + } + } as GlobalMemoryPrompt +} + +function createMockFastCommandPrompt( + commandName: string, + series?: string +): FastCommandPrompt { + const content = 'Run something' + return { + type: PromptKind.FastCommand, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', MOCK_WORKSPACE_DIR), + markdownContents: [], + yamlFrontMatter: { + description: 'Fast command' + }, + ...series != null && {series}, + commandName + } as FastCommandPrompt +} + +function createMockSkillPrompt( + name: string, + description: string, + content: string, + options?: { + mcpConfig?: {rawContent: string, mcpServers: Record} + childDocs?: {relativePath: string, content: string}[] + resources?: {relativePath: string, content: string, encoding: 'text' | 'base64'}[] + } +): SkillPrompt { + return { + type: PromptKind.Skill, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath(name, MOCK_WORKSPACE_DIR), + markdownContents: [], + yamlFrontMatter: { + name, + description + }, + mcpConfig: options?.mcpConfig != null + ? { + type: PromptKind.SkillMcpConfig, + rawContent: options.mcpConfig.rawContent, + mcpServers: options.mcpConfig.mcpServers + } + : void 0, + childDocs: options?.childDocs, + resources: options?.resources + } as SkillPrompt +} + +function createMockOutputPluginContext( + collectedInputContext: Partial +): OutputPluginContext { + return { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), + projects: [] + }, + ideConfigFiles: [], + ...collectedInputContext + } as CollectedInputContext + } +} + +function createMockOutputWriteContext( + collectedInputContext: Partial, + dryRun = false +): OutputWriteContext { + return { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), + projects: [] + }, + ideConfigFiles: [], + ...collectedInputContext + } as CollectedInputContext, + dryRun + } +} + +describe('qoder IDE plugin output plugin', () => { + let plugin: TestableQoderIDEPluginOutputPlugin + + beforeEach(() => { + plugin = new TestableQoderIDEPluginOutputPlugin() + plugin.setMockHomeDir('/home/test') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.mkdirSync).mockReturnValue(void 0) + vi.mocked(fs.writeFileSync).mockReturnValue(void 0) + }) + + afterEach(() => vi.clearAllMocks()) + + describe('registerProjectOutputDirs', () => { + it('should register .qoder/rules for each project', async () => { + const ctx = createMockOutputPluginContext({ + workspace: { + directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), + projects: [ + {dirFromWorkspacePath: createMockRelativePath('project-a', MOCK_WORKSPACE_DIR)}, + {dirFromWorkspacePath: createMockRelativePath('project-b', MOCK_WORKSPACE_DIR)} + ] + } + }) + + const results = await plugin.registerProjectOutputDirs(ctx) + + expect(results).toHaveLength(2) + expect(results[0].path).toBe(path.join('project-a', '.qoder', 'rules')) + expect(results[1].path).toBe(path.join('project-b', '.qoder', 'rules')) + }) + }) + + describe('registerProjectOutputFiles', () => { + it('should register global.md, always.md, and child glob rules', async () => { + const projectDir = createMockRelativePath('project-a', MOCK_WORKSPACE_DIR) + const ctx = createMockOutputPluginContext({ + globalMemory: createMockGlobalMemoryPrompt('Global rules', MOCK_WORKSPACE_DIR), + workspace: { + directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), + projects: [ + { + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: createMockRootMemoryPrompt('Root rules', MOCK_WORKSPACE_DIR), + childMemoryPrompts: [ + createMockChildMemoryPrompt('Child rules', 'project-a/src', MOCK_WORKSPACE_DIR, 'src') + ] + } + ] + } + }) + + const results = await plugin.registerProjectOutputFiles(ctx) + + const paths = results.map(r => r.path) + expect(paths).toContain(path.join('project-a', '.qoder', 'rules', 'global.md')) + expect(paths).toContain(path.join('project-a', '.qoder', 'rules', 'always.md')) + expect(paths).toContain(path.join('project-a', '.qoder', 'rules', 'glob-src.md')) + }) + }) + + describe('registerGlobalOutputDirs', () => { + it('should return empty when no fast commands exist', async () => { + const ctx = createMockOutputPluginContext({}) + const results = await plugin.registerGlobalOutputDirs(ctx) + expect(results).toHaveLength(0) + }) + + it('should register ~/.qoder/commands when fast commands exist', async () => { + const ctx = createMockOutputPluginContext({ + fastCommands: [createMockFastCommandPrompt('compile')] + }) + + const results = await plugin.registerGlobalOutputDirs(ctx) + + expect(results).toHaveLength(1) + expect(results[0].basePath).toBe(path.join('/home/test', '.qoder')) + expect(results[0].path).toBe('commands') + }) + + it('should register ~/.qoder/skills/ for each skill', async () => { + const ctx = createMockOutputPluginContext({ + skills: [ + createMockSkillPrompt('my-skill', 'A test skill', 'Skill content'), + createMockSkillPrompt('another-skill', 'Another skill', 'More content') + ] + }) + + const results = await plugin.registerGlobalOutputDirs(ctx) + + expect(results).toHaveLength(2) + expect(results[0].path).toBe(path.join('skills', 'my-skill')) + expect(results[1].path).toBe(path.join('skills', 'another-skill')) + }) + }) + + describe('registerGlobalOutputFiles', () => { + it('should register fast command files under ~/.qoder/commands', async () => { + const ctx = createMockOutputPluginContext({ + fastCommands: [ + createMockFastCommandPrompt('compile', 'build'), + createMockFastCommandPrompt('test') + ] + }) + + const results = await plugin.registerGlobalOutputFiles(ctx) + + const paths = results.map(r => r.path) + expect(paths).toContain(path.join('commands', 'build-compile.md')) + expect(paths).toContain(path.join('commands', 'test.md')) + }) + + it('should register skill files under ~/.qoder/skills', async () => { + const ctx = createMockOutputPluginContext({ + skills: [ + createMockSkillPrompt('my-skill', 'A test skill', 'Skill content') + ] + }) + + const results = await plugin.registerGlobalOutputFiles(ctx) + + const paths = results.map(r => r.path) + expect(paths).toContain(path.join('skills', 'my-skill', 'SKILL.md')) + }) + + it('should register mcp.json when skill has MCP config', async () => { + const ctx = createMockOutputPluginContext({ + skills: [ + createMockSkillPrompt('my-skill', 'A test skill', 'Skill content', { + mcpConfig: { + rawContent: '{"mcpServers": {}}', + mcpServers: {} + } + }) + ] + }) + + const results = await plugin.registerGlobalOutputFiles(ctx) + + const paths = results.map(r => r.path) + expect(paths).toContain(path.join('skills', 'my-skill', 'SKILL.md')) + expect(paths).toContain(path.join('skills', 'my-skill', 'mcp.json')) + }) + + it('should register child docs and resources', async () => { + const ctx = createMockOutputPluginContext({ + skills: [ + createMockSkillPrompt('my-skill', 'A test skill', 'Skill content', { + childDocs: [{relativePath: 'docs/guide.mdx', content: 'Guide content'}], + resources: [{relativePath: 'assets/image.png', content: 'base64data', encoding: 'base64'}] + }) + ] + }) + + const results = await plugin.registerGlobalOutputFiles(ctx) + + const paths = results.map(r => r.path) + expect(paths).toContain(path.join('skills', 'my-skill', 'SKILL.md')) + expect(paths).toContain(path.join('skills', 'my-skill', 'docs', 'guide.md')) + expect(paths).toContain(path.join('skills', 'my-skill', 'assets', 'image.png')) + }) + }) + + describe('canWrite', () => { + it('should return true when project prompts exist', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), + projects: [ + { + dirFromWorkspacePath: createMockRelativePath('project-a', MOCK_WORKSPACE_DIR), + rootMemoryPrompt: createMockRootMemoryPrompt('Root rules', MOCK_WORKSPACE_DIR) + } + ] + } + }) + + const result = await plugin.canWrite(ctx) + expect(result).toBe(true) + }) + + it('should return true when skills exist', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), + projects: [] + }, + skills: [createMockSkillPrompt('my-skill', 'A test skill', 'Skill content')] + }) + + const result = await plugin.canWrite(ctx) + expect(result).toBe(true) + }) + + it('should return false when nothing to write', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), + projects: [] + } + }) + + const result = await plugin.canWrite(ctx) + expect(result).toBe(false) + }) + }) + + describe('writeProjectOutputs', () => { + it('should write global, root, and child rule files with front matter', async () => { + const projectDir = createMockRelativePath('project-a', MOCK_WORKSPACE_DIR) + const ctx = createMockOutputWriteContext({ + globalMemory: createMockGlobalMemoryPrompt('Global rules', MOCK_WORKSPACE_DIR), + workspace: { + directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), + projects: [ + { + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: createMockRootMemoryPrompt('Root rules', MOCK_WORKSPACE_DIR), + childMemoryPrompts: [ + createMockChildMemoryPrompt('Child rules', 'project-a/src', MOCK_WORKSPACE_DIR, 'src') + ] + } + ] + } + }) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files).toHaveLength(3) + + const [calls] = [vi.mocked(fs.writeFileSync).mock.calls] + expect(calls).toHaveLength(3) + + const globalCall = calls.find(call => String(call[0]).includes(path.join('project-a', '.qoder', 'rules', 'global.md'))) + const rootCall = calls.find(call => String(call[0]).includes(path.join('project-a', '.qoder', 'rules', 'always.md'))) + const childCall = calls.find(call => String(call[0]).includes(path.join('project-a', '.qoder', 'rules', 'glob-src.md'))) + + expect(globalCall).toBeDefined() + expect(rootCall).toBeDefined() + expect(childCall).toBeDefined() + + expect(String(globalCall?.[1])).toContain('trigger: always_on') + expect(String(globalCall?.[1])).toContain('Global rules') + + expect(String(rootCall?.[1])).toContain('trigger: always_on') + expect(String(rootCall?.[1])).toContain('Root rules') + + expect(String(childCall?.[1])).toContain('trigger: glob') + expect(String(childCall?.[1])).toContain('glob: src/**') + expect(String(childCall?.[1])).toContain('Child rules') + }) + }) + + describe('writeGlobalOutputs', () => { + it('should write fast command files with front matter', async () => { + const ctx = createMockOutputWriteContext({ + fastCommands: [ + createMockFastCommandPrompt('compile', 'build') + ] + }) + + const results = await plugin.writeGlobalOutputs(ctx) + + expect(results.files).toHaveLength(1) + + const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] + expect(String(writeCall?.[0])).toContain(path.join('.qoder', 'commands', 'build-compile.md')) + expect(String(writeCall?.[1])).toContain('description: Fast command') + expect(String(writeCall?.[1])).toContain('Run something') + }) + + it('should write skill files to ~/.qoder/skills/', async () => { + const ctx = createMockOutputWriteContext({ + skills: [ + createMockSkillPrompt('my-skill', 'A test skill', 'Skill content') + ] + }) + + const results = await plugin.writeGlobalOutputs(ctx) + + expect(results.files).toHaveLength(1) + + const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] + expect(String(writeCall?.[0])).toContain(path.join('.qoder', 'skills', 'my-skill', 'SKILL.md')) + expect(String(writeCall?.[1])).toContain('name: my-skill') + expect(String(writeCall?.[1])).toContain('description: A test skill') + expect(String(writeCall?.[1])).toContain('Skill content') + }) + + it('should write mcp.json when skill has MCP config', async () => { + const ctx = createMockOutputWriteContext({ + skills: [ + createMockSkillPrompt('my-skill', 'A test skill', 'Skill content', { + mcpConfig: { + rawContent: '{"mcpServers": {"test-server": {}}}', + mcpServers: {'test-server': {}} + } + }) + ] + }) + + const results = await plugin.writeGlobalOutputs(ctx) + + expect(results.files).toHaveLength(2) + + const writeCalls = vi.mocked(fs.writeFileSync).mock.calls + const mcpCall = writeCalls.find(call => String(call[0]).includes('mcp.json')) + expect(mcpCall).toBeDefined() + expect(String(mcpCall?.[1])).toContain('mcpServers') + }) + }) +}) diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts new file mode 100644 index 00000000..eb3ef122 --- /dev/null +++ b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts @@ -0,0 +1,426 @@ +import type { + FastCommandPrompt, + OutputPluginContext, + OutputWriteContext, + ProjectChildrenMemoryPrompt, + RulePrompt, + SkillPrompt, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import {Buffer} from 'node:buffer' +import * as path from 'node:path' +import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' + +const QODER_CONFIG_DIR = '.qoder' +const RULES_SUBDIR = 'rules' +const COMMANDS_SUBDIR = 'commands' +const SKILLS_SUBDIR = 'skills' +const GLOBAL_RULE_FILE = 'global.md' +const PROJECT_RULE_FILE = 'always.md' +const CHILD_RULE_FILE_PREFIX = 'glob-' +const SKILL_FILE_NAME = 'SKILL.md' +const MCP_CONFIG_FILE = 'mcp.json' +const TRIGGER_ALWAYS = 'always_on' +const TRIGGER_GLOB = 'glob' +const RULE_GLOB_KEY = 'glob' +const RULE_FILE_PREFIX = 'rule-' + +export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('QoderIDEPluginOutputPlugin', {globalConfigDir: QODER_CONFIG_DIR, indexignore: '.qoderignore'}) + } + + async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + return projects + .filter(p => p.dirFromWorkspacePath != null) + .map(p => this.createProjectRulesDirPath(p.dirFromWorkspacePath!)) + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {workspace, rules} = ctx.collectedInputContext + const {projects} = workspace + const {globalMemory} = ctx.collectedInputContext + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + + if (globalMemory != null) results.push(this.createProjectRuleFilePath(projectDir, GLOBAL_RULE_FILE)) + + if (project.rootMemoryPrompt != null) results.push(this.createProjectRuleFilePath(projectDir, PROJECT_RULE_FILE)) + + if (project.childMemoryPrompts != null) { + for (const child of project.childMemoryPrompts) results.push(this.createProjectRuleFilePath(projectDir, this.buildChildRuleFileName(child))) + } + + if (rules != null && rules.length > 0) { // Handle project rules + const projectRules = applySubSeriesGlobPrefix( + filterRulesByProjectConfig( + rules.filter(r => this.normalizeRuleScope(r) === 'project'), + project.projectConfig + ), + project.projectConfig + ) + for (const rule of projectRules) { + const fileName = this.buildRuleFileName(rule) + results.push(this.createProjectRuleFilePath(projectDir, fileName)) + } + } + } + results.push(...this.registerProjectIgnoreOutputFiles(projects)) + return results + } + + async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { + const globalDir = this.getGlobalConfigDir() + const {fastCommands, skills, rules} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const results: RelativePath[] = [] + + if (fastCommands != null && fastCommands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + if (filteredCommands.length > 0) results.push(this.createRelativePath(COMMANDS_SUBDIR, globalDir, () => COMMANDS_SUBDIR)) + } + + if (skills != null && skills.length > 0) { + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter.name + results.push(this.createRelativePath( + path.join(SKILLS_SUBDIR, skillName), + globalDir, + () => skillName + )) + } + } + + const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global') + if (globalRules != null && globalRules.length > 0) { + results.push(this.createRelativePath( + path.join(RULES_SUBDIR), + globalDir, + () => RULES_SUBDIR + )) + } + return results + } + + async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { + const globalDir = this.getGlobalConfigDir() + const {fastCommands, skills, rules} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const results: RelativePath[] = [] + const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + + if (fastCommands != null && fastCommands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + for (const cmd of filteredCommands) { + const fileName = this.transformFastCommandName(cmd, transformOptions) + results.push(this.createRelativePath( + path.join(COMMANDS_SUBDIR, fileName), + globalDir, + () => COMMANDS_SUBDIR + )) + } + } + + const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global') + if (globalRules != null && globalRules.length > 0) { + for (const rule of globalRules) { + const fileName = this.buildRuleFileName(rule) + results.push(this.createRelativePath( + path.join(RULES_SUBDIR, fileName), + globalDir, + () => RULES_SUBDIR + )) + } + } + + const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] + if (filteredSkills.length > 0) { + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter.name + results.push(this.createRelativePath( + path.join(SKILLS_SUBDIR, skillName, SKILL_FILE_NAME), + globalDir, + () => skillName + )) + + if (skill.mcpConfig != null) { + results.push(this.createRelativePath( + path.join(SKILLS_SUBDIR, skillName, MCP_CONFIG_FILE), + globalDir, + () => skillName + )) + } + + if (skill.childDocs != null) { + for (const childDoc of skill.childDocs) { + results.push(this.createRelativePath( + path.join(SKILLS_SUBDIR, skillName, childDoc.relativePath.replace(/\.mdx$/, '.md')), + globalDir, + () => skillName + )) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + results.push(this.createRelativePath( + path.join(SKILLS_SUBDIR, skillName, resource.relativePath), + globalDir, + () => skillName + )) + } + } + } + } + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {workspace, globalMemory, fastCommands, skills, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + const hasProjectPrompts = workspace.projects.some( + p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 + ) + const hasRules = (rules?.length ?? 0) > 0 + const hasQoderIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.qoderignore') ?? false + if (hasProjectPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0 || hasRules || hasQoderIgnore) return true + this.log.trace({action: 'skip', reason: 'noOutputs'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const {workspace, globalMemory, rules} = ctx.collectedInputContext + const {projects} = workspace + const fileResults: WriteResult[] = [] + + for (const project of projects) { + if (project.dirFromWorkspacePath == null) continue + const projectDir = project.dirFromWorkspacePath + + if (globalMemory != null) { + const content = this.buildAlwaysRuleContent(globalMemory.content as string) + fileResults.push(await this.writeProjectRuleFile(ctx, projectDir, GLOBAL_RULE_FILE, content, 'globalRule')) + } + + if (project.rootMemoryPrompt != null) { + const content = this.buildAlwaysRuleContent(project.rootMemoryPrompt.content as string) + fileResults.push(await this.writeProjectRuleFile(ctx, projectDir, PROJECT_RULE_FILE, content, 'projectRootRule')) + } + + if (project.childMemoryPrompts != null) { + for (const child of project.childMemoryPrompts) { + const fileName = this.buildChildRuleFileName(child) + const content = this.buildGlobRuleContent(child) + fileResults.push(await this.writeProjectRuleFile(ctx, projectDir, fileName, content, 'projectChildRule')) + } + } + + if (rules != null && rules.length > 0) { // Write project rules + const projectRules = applySubSeriesGlobPrefix( + filterRulesByProjectConfig( + rules.filter(r => this.normalizeRuleScope(r) === 'project'), + project.projectConfig + ), + project.projectConfig + ) + for (const rule of projectRules) { + const fileName = this.buildRuleFileName(rule) + const content = this.buildRuleContent(rule) + fileResults.push(await this.writeProjectRuleFile(ctx, projectDir, fileName, content, 'projectRule')) + } + } + } + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) + return {files: fileResults, dirs: []} + } + + async writeGlobalOutputs(ctx: OutputWriteContext): Promise { + const {fastCommands, skills, rules} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const fileResults: WriteResult[] = [] + const globalDir = this.getGlobalConfigDir() + const commandsDir = path.join(globalDir, COMMANDS_SUBDIR) + const skillsDir = path.join(globalDir, SKILLS_SUBDIR) + const rulesDir = path.join(globalDir, RULES_SUBDIR) + + if (fastCommands != null && fastCommands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalFastCommand(ctx, commandsDir, cmd)) + } + + if (rules != null && rules.length > 0) { + const globalRules = rules.filter(r => this.normalizeRuleScope(r) === 'global') + for (const rule of globalRules) fileResults.push(await this.writeRuleFile(ctx, rulesDir, rule)) + } + + if (skills == null || skills.length === 0) return {files: fileResults, dirs: []} + + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) fileResults.push(...await this.writeGlobalSkill(ctx, skillsDir, skill)) + return {files: fileResults, dirs: []} + } + + private createProjectRulesDirPath(projectDir: RelativePath): RelativePath { + return this.createRelativePath( + path.join(projectDir.path, QODER_CONFIG_DIR, RULES_SUBDIR), + projectDir.basePath, + () => RULES_SUBDIR + ) + } + + private createProjectRuleFilePath(projectDir: RelativePath, fileName: string): RelativePath { + return this.createRelativePath( + path.join(projectDir.path, QODER_CONFIG_DIR, RULES_SUBDIR, fileName), + projectDir.basePath, + () => RULES_SUBDIR + ) + } + + private buildChildRuleFileName(child: ProjectChildrenMemoryPrompt): string { + const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path + const normalized = childPath.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '').replaceAll('/', '-') + return `${CHILD_RULE_FILE_PREFIX}${normalized.length > 0 ? normalized : 'root'}.md` + } + + private buildAlwaysRuleContent(content: string): string { + return buildMarkdownWithFrontMatter({trigger: TRIGGER_ALWAYS, type: 'user_command'}, content) + } + + private buildGlobRuleContent(child: ProjectChildrenMemoryPrompt): string { + const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path + const normalized = childPath.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '') + const pattern = normalized.length === 0 ? '**/*' : `${normalized}/**` + return buildMarkdownWithFrontMatter({trigger: TRIGGER_GLOB, [RULE_GLOB_KEY]: pattern, type: 'user_command'}, child.content as string) + } + + private async writeProjectRuleFile( + ctx: OutputWriteContext, + projectDir: RelativePath, + fileName: string, + content: string, + label: string + ): Promise { + const rulesDir = path.join(projectDir.basePath, projectDir.path, QODER_CONFIG_DIR, RULES_SUBDIR) + const fullPath = path.join(rulesDir, fileName) + return this.writeFile(ctx, fullPath, content, label) + } + + private async writeGlobalFastCommand( + ctx: OutputWriteContext, + commandsDir: string, + cmd: FastCommandPrompt + ): Promise { + const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + const fileName = this.transformFastCommandName(cmd, transformOptions) + const fullPath = path.join(commandsDir, fileName) + const fmData = this.buildFastCommandFrontMatter(cmd) + const content = buildMarkdownWithFrontMatter(fmData, cmd.content) + return this.writeFile(ctx, fullPath, content, 'globalFastCommand') + } + + private async writeRuleFile( + ctx: OutputWriteContext, + rulesDir: string, + rule: RulePrompt + ): Promise { + const fileName = this.buildRuleFileName(rule) + const fullPath = path.join(rulesDir, fileName) + const content = this.buildRuleContent(rule) + return this.writeFile(ctx, fullPath, content, 'rule') + } + + private async writeGlobalSkill( + ctx: OutputWriteContext, + skillsDir: string, + skill: SkillPrompt + ): Promise { + const results: WriteResult[] = [] + const skillName = skill.yamlFrontMatter.name + const skillDir = path.join(skillsDir, skillName) + const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) + + const fmData = this.buildSkillFrontMatter(skill) + const content = buildMarkdownWithFrontMatter(fmData, skill.content as string) + results.push(await this.writeFile(ctx, skillFilePath, content, 'skill')) + + if (skill.mcpConfig != null) { + const mcpPath = path.join(skillDir, MCP_CONFIG_FILE) + results.push(await this.writeFile(ctx, mcpPath, skill.mcpConfig.rawContent, 'mcpConfig')) + } + + if (skill.childDocs != null) { + for (const childDoc of skill.childDocs) { + const childPath = path.join(skillDir, childDoc.relativePath.replace(/\.mdx$/, '.md')) + results.push(await this.writeFile(ctx, childPath, childDoc.content as string, 'childDoc')) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + const resourcePath = path.join(skillDir, resource.relativePath) + if (resource.encoding === 'base64') { + const buffer = Buffer.from(resource.content, 'base64') + const dir = path.dirname(resourcePath) + this.ensureDirectory(dir) + this.writeFileSyncBuffer(resourcePath, buffer) + results.push({ + path: this.createRelativePath(resource.relativePath, skillDir, () => skillName), + success: true + }) + } else results.push(await this.writeFile(ctx, resourcePath, resource.content, 'resource')) + } + } + return results + } + + private buildSkillFrontMatter(skill: SkillPrompt): Record { + const fm = skill.yamlFrontMatter + return { + name: fm.name, + description: fm.description, + type: 'user_command', + ...fm.displayName != null && {displayName: fm.displayName}, + ...fm.keywords != null && fm.keywords.length > 0 && {keywords: fm.keywords}, + ...fm.author != null && {author: fm.author}, + ...fm.version != null && {version: fm.version}, + ...fm.allowTools != null && fm.allowTools.length > 0 && {allowTools: fm.allowTools} + } + } + + private buildFastCommandFrontMatter(cmd: FastCommandPrompt): Record { + const fm = cmd.yamlFrontMatter + if (fm == null) return {description: 'Fast command', type: 'user_command'} + return { + description: fm.description, + type: 'user_command', + ...fm.argumentHint != null && {argumentHint: fm.argumentHint}, + ...fm.allowTools != null && fm.allowTools.length > 0 && {allowTools: fm.allowTools} + } + } + + private buildRuleFileName(rule: RulePrompt): string { + return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` + } + + private buildRuleContent(rule: RulePrompt): string { + const fmData: Record = { + trigger: TRIGGER_GLOB, + [RULE_GLOB_KEY]: rule.globs.length > 0 ? rule.globs.join(', ') : '**/*', + type: 'user_command' + } + return buildMarkdownWithFrontMatter(fmData, rule.content) + } + + protected override normalizeRuleScope(rule: RulePrompt): 'global' | 'project' { + return rule.scope || 'global' + } +} diff --git a/cli/src/plugins/plugin-qoder-ide/index.ts b/cli/src/plugins/plugin-qoder-ide/index.ts new file mode 100644 index 00000000..4573a43c --- /dev/null +++ b/cli/src/plugins/plugin-qoder-ide/index.ts @@ -0,0 +1,3 @@ +export { + QoderIDEPluginOutputPlugin +} from './QoderIDEPluginOutputPlugin' diff --git a/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.property.test.ts b/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.property.test.ts new file mode 100644 index 00000000..ed73f513 --- /dev/null +++ b/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.property.test.ts @@ -0,0 +1,499 @@ +import type { + CollectedInputContext, + OutputPluginContext, + OutputWriteContext, + ReadmeFileKind, + ReadmePrompt, + RelativePath, + Workspace +} from '@truenine/plugin-shared' + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {createLogger, FilePathKind, PromptKind, README_FILE_KIND_MAP} from '@truenine/plugin-shared' +import * as fc from 'fast-check' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {ReadmeMdConfigFileOutputPlugin} from './ReadmeMdConfigFileOutputPlugin' + +/** + * Feature: readme-md-plugin + * Property-based tests for ReadmeMdConfigFileOutputPlugin + */ +describe('readmeMdConfigFileOutputPlugin property tests', () => { + const plugin = new ReadmeMdConfigFileOutputPlugin() + let tempDir: string + + const allFileKinds = Object.keys(README_FILE_KIND_MAP) as ReadmeFileKind[] + + beforeEach(() => tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'readme-output-test-'))) + + afterEach(() => fs.rmSync(tempDir, {recursive: true, force: true})) + + function createReadmePrompt( + projectName: string, + content: string, + isRoot: boolean, + basePath: string, + subdir?: string, + fileKind: ReadmeFileKind = 'Readme' + ): ReadmePrompt { + const targetPath = isRoot ? projectName : path.join(projectName, subdir ?? '') + + const targetDir: RelativePath = { + pathKind: FilePathKind.Relative, + path: targetPath, + basePath, + getDirectoryName: () => isRoot ? projectName : path.basename(subdir ?? ''), + getAbsolutePath: () => path.resolve(basePath, targetPath) + } + + const dir: RelativePath = { + pathKind: FilePathKind.Relative, + path: targetPath, + basePath, + getDirectoryName: () => isRoot ? projectName : path.basename(subdir ?? ''), + getAbsolutePath: () => path.resolve(basePath, targetPath) + } + + return { + type: PromptKind.Readme, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + projectName, + targetDir, + isRoot, + fileKind, + markdownContents: [], + dir + } + } + + function createMockPluginContext( + readmePrompts: readonly ReadmePrompt[], + basePath: string + ): OutputPluginContext { + const workspace: Workspace = { + directory: { + pathKind: FilePathKind.Absolute, + path: basePath, + getDirectoryName: () => path.basename(basePath), + getAbsolutePath: () => basePath + }, + projects: [] + } + + const collectedInputContext: CollectedInputContext = { + workspace, + ideConfigFiles: [], + readmePrompts + } + + return { + collectedInputContext, + logger: createLogger('test', 'error'), + fs, + path, + glob: {} as typeof import('fast-glob') + } + } + + function createMockWriteContext( + readmePrompts: readonly ReadmePrompt[], + basePath: string, + dryRun: boolean = false + ): OutputWriteContext { + const pluginCtx = createMockPluginContext(readmePrompts, basePath) + return { + ...pluginCtx, + dryRun + } + } + + describe('property 3: Output Path Mapping', () => { + const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) + .filter(s => s.trim().length > 0) + + const fileKindArb = fc.constantFrom(...allFileKinds) + + it('should register correct output paths for root READMEs', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + async (projectName, content) => { + const readme = createReadmePrompt(projectName, content, true, tempDir) + const ctx = createMockPluginContext([readme], tempDir) + + const registeredPaths = await plugin.registerProjectOutputFiles(ctx) + + expect(registeredPaths.length).toBe(1) + expect(registeredPaths[0].path).toBe(path.join(projectName, 'README.md')) + expect(registeredPaths[0].basePath).toBe(tempDir) + expect(registeredPaths[0].getAbsolutePath()).toBe( + path.join(tempDir, projectName, 'README.md') + ) + } + ), + {numRuns: 100} + ) + }) + + it('should register correct output paths for child READMEs', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + subdirNameArb, + readmeContentArb, + async (projectName, subdir, content) => { + const readme = createReadmePrompt(projectName, content, false, tempDir, subdir) + const ctx = createMockPluginContext([readme], tempDir) + + const registeredPaths = await plugin.registerProjectOutputFiles(ctx) + + expect(registeredPaths.length).toBe(1) + expect(registeredPaths[0].path).toBe(path.join(projectName, subdir, 'README.md')) + expect(registeredPaths[0].basePath).toBe(tempDir) + expect(registeredPaths[0].getAbsolutePath()).toBe( + path.join(tempDir, projectName, subdir, 'README.md') + ) + } + ), + {numRuns: 100} + ) + }) + + it('should write root README to correct path', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + async (projectName, content) => { + const readme = createReadmePrompt(projectName, content, true, tempDir) + const ctx = createMockWriteContext([readme], tempDir, false) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files.length).toBe(1) + expect(results.files[0].success).toBe(true) + + const expectedPath = path.join(tempDir, projectName, 'README.md') + expect(fs.existsSync(expectedPath)).toBe(true) + expect(fs.readFileSync(expectedPath, 'utf8')).toBe(content) + } + ), + {numRuns: 100} + ) + }) + + it('should write child README to correct path', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + subdirNameArb, + readmeContentArb, + async (projectName, subdir, content) => { + const readme = createReadmePrompt(projectName, content, false, tempDir, subdir) + const ctx = createMockWriteContext([readme], tempDir, false) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files.length).toBe(1) + expect(results.files[0].success).toBe(true) + + const expectedPath = path.join(tempDir, projectName, subdir, 'README.md') + expect(fs.existsSync(expectedPath)).toBe(true) + expect(fs.readFileSync(expectedPath, 'utf8')).toBe(content) + } + ), + {numRuns: 100} + ) + }) + + it('should register correct output path per fileKind', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + fileKindArb, + async (projectName, content, fileKind) => { + const readme = createReadmePrompt(projectName, content, true, tempDir, void 0, fileKind) + const ctx = createMockPluginContext([readme], tempDir) + + const registeredPaths = await plugin.registerProjectOutputFiles(ctx) + const expectedFileName = README_FILE_KIND_MAP[fileKind].out + + expect(registeredPaths.length).toBe(1) + expect(registeredPaths[0].path).toBe(path.join(projectName, expectedFileName)) + } + ), + {numRuns: 100} + ) + }) + + it('should write correct output file per fileKind', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + fileKindArb, + async (projectName, content, fileKind) => { + const readme = createReadmePrompt(projectName, content, true, tempDir, void 0, fileKind) + const ctx = createMockWriteContext([readme], tempDir, false) + + const results = await plugin.writeProjectOutputs(ctx) + const expectedFileName = README_FILE_KIND_MAP[fileKind].out + + expect(results.files.length).toBe(1) + expect(results.files[0].success).toBe(true) + + const expectedPath = path.join(tempDir, projectName, expectedFileName) + expect(fs.existsSync(expectedPath)).toBe(true) + expect(fs.readFileSync(expectedPath, 'utf8')).toBe(content) + } + ), + {numRuns: 100} + ) + }) + + it('should write all three file kinds to separate files in same project', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + async (projectName, content) => { + const readmes = allFileKinds.map(kind => + createReadmePrompt(projectName, `${content}-${kind}`, true, tempDir, void 0, kind)) + const ctx = createMockWriteContext(readmes, tempDir, false) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files.length).toBe(3) + expect(results.files.every(r => r.success)).toBe(true) + + for (const kind of allFileKinds) { + const expectedFileName = README_FILE_KIND_MAP[kind].out + const expectedPath = path.join(tempDir, projectName, expectedFileName) + expect(fs.existsSync(expectedPath)).toBe(true) + expect(fs.readFileSync(expectedPath, 'utf8')).toBe(`${content}-${kind}`) + } + } + ), + {numRuns: 100} + ) + }) + }) + + describe('property 4: Dry-Run Idempotence', () => { + const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) + .filter(s => s.trim().length > 0) + + const fileKindArb = fc.constantFrom(...allFileKinds) + + it('should not create any files in dry-run mode', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + fc.boolean(), + fc.option(subdirNameArb, {nil: void 0}), + fileKindArb, + async (projectName, content, isRoot, subdir, fileKind) => { + const readme = createReadmePrompt(projectName, content, isRoot, tempDir, isRoot ? void 0 : subdir ?? 'subdir', fileKind) + const ctx = createMockWriteContext([readme], tempDir, true) + + const filesBefore = fs.readdirSync(tempDir, {recursive: true}) + + await plugin.writeProjectOutputs(ctx) + + const filesAfter = fs.readdirSync(tempDir, {recursive: true}) + expect(filesAfter).toEqual(filesBefore) + } + ), + {numRuns: 100} + ) + }) + + it('should return success results for all planned operations in dry-run mode', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(projectNameArb, {minLength: 1, maxLength: 5}), + readmeContentArb, + async (projectNames, content) => { + const uniqueProjects = [...new Set(projectNames)] + const readmes = uniqueProjects.map(name => + createReadmePrompt(name, content, true, tempDir)) + const ctx = createMockWriteContext(readmes, tempDir, true) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files.length).toBe(uniqueProjects.length) + for (const result of results.files) { + expect(result.success).toBe(true) + expect(result.skipped).toBe(false) + } + } + ), + {numRuns: 100} + ) + }) + + it('should report same operations in dry-run and normal mode', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + async (projectName, content) => { + const readme = createReadmePrompt(projectName, content, true, tempDir) + + const dryRunCtx = createMockWriteContext([readme], tempDir, true) + const dryRunResults = await plugin.writeProjectOutputs(dryRunCtx) + + const normalCtx = createMockWriteContext([readme], tempDir, false) + const normalResults = await plugin.writeProjectOutputs(normalCtx) + + expect(dryRunResults.files.length).toBe(normalResults.files.length) + + for (let i = 0; i < dryRunResults.files.length; i++) expect(dryRunResults.files[i].path.path).toBe(normalResults.files[i].path.path) + } + ), + {numRuns: 100} + ) + }) + + it('should not create files in dry-run mode for all fileKinds', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + async (projectName, content) => { + const readmes = allFileKinds.map(kind => + createReadmePrompt(projectName, `${content}-${kind}`, true, tempDir, void 0, kind)) + const ctx = createMockWriteContext(readmes, tempDir, true) + + const filesBefore = fs.readdirSync(tempDir, {recursive: true}) + + await plugin.writeProjectOutputs(ctx) + + const filesAfter = fs.readdirSync(tempDir, {recursive: true}) + expect(filesAfter).toEqual(filesBefore) + } + ), + {numRuns: 100} + ) + }) + }) + + describe('property 5: Clean Operation Completeness', () => { + const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) + .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) + + const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) + .filter(s => s.trim().length > 0) + + it('should register all output file paths for cleanup', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(projectNameArb, {minLength: 1, maxLength: 5}), + readmeContentArb, + async (projectNames, content) => { + const uniqueProjects = [...new Set(projectNames)] + const readmes = uniqueProjects.map(name => + createReadmePrompt(name, content, true, tempDir)) + const ctx = createMockPluginContext(readmes, tempDir) + + const registeredPaths = await plugin.registerProjectOutputFiles(ctx) + + expect(registeredPaths.length).toBe(uniqueProjects.length) + + for (const registeredPath of registeredPaths) expect(registeredPath.path.endsWith('README.md')).toBe(true) + } + ), + {numRuns: 100} + ) + }) + + it('should register paths for both root and child READMEs', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + subdirNameArb, + readmeContentArb, + async (projectName, subdir, content) => { + const rootReadme = createReadmePrompt(projectName, content, true, tempDir) + const childReadme = createReadmePrompt(projectName, content, false, tempDir, subdir) + const ctx = createMockPluginContext([rootReadme, childReadme], tempDir) + + const registeredPaths = await plugin.registerProjectOutputFiles(ctx) + + expect(registeredPaths.length).toBe(2) + + const rootPath = registeredPaths.find(p => p.path === path.join(projectName, 'README.md')) + const childPath = registeredPaths.find(p => p.path === path.join(projectName, subdir, 'README.md')) + + expect(rootPath).toBeDefined() + expect(childPath).toBeDefined() + } + ), + {numRuns: 100} + ) + }) + + it('should return empty array when no README prompts exist', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + async () => { + const ctx = createMockPluginContext([], tempDir) + + const registeredPaths = await plugin.registerProjectOutputFiles(ctx) + + expect(registeredPaths).toEqual([]) + } + ), + {numRuns: 100} + ) + }) + + it('should register correct paths for all fileKinds', async () => { + await fc.assert( + fc.asyncProperty( + projectNameArb, + readmeContentArb, + async (projectName, content) => { + const readmes = allFileKinds.map(kind => + createReadmePrompt(projectName, `${content}-${kind}`, true, tempDir, void 0, kind)) + const ctx = createMockPluginContext(readmes, tempDir) + + const registeredPaths = await plugin.registerProjectOutputFiles(ctx) + + expect(registeredPaths.length).toBe(3) + + for (const kind of allFileKinds) { + const expectedFileName = README_FILE_KIND_MAP[kind].out + const found = registeredPaths.find(p => p.path === path.join(projectName, expectedFileName)) + expect(found).toBeDefined() + } + } + ), + {numRuns: 100} + ) + }) + }) +}) diff --git a/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.ts b/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.ts new file mode 100644 index 00000000..44f7fe75 --- /dev/null +++ b/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.ts @@ -0,0 +1,128 @@ +import type { + OutputPluginContext, + OutputWriteContext, + ReadmeFileKind, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' + +import * as fs from 'node:fs' +import * as path from 'node:path' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {FilePathKind, README_FILE_KIND_MAP} from '@truenine/plugin-shared' + +function resolveOutputFileName(fileKind?: ReadmeFileKind): string { + return README_FILE_KIND_MAP[fileKind ?? 'Readme'].out +} + +/** + * Output plugin for writing readme-family files to project directories. + * Reads README prompts collected by ReadmeMdInputPlugin and writes them + * to the corresponding project directories. + * + * Output mapping: + * - fileKind=Readme → README.md + * - fileKind=CodeOfConduct → CODE_OF_CONDUCT.md + * - fileKind=Security → SECURITY.md + * + * Supports: + * - Root files (written to project root) + * - Child files (written to project subdirectories) + * - Dry-run mode (preview without writing) + * - Clean operation (delete generated files) + */ +export class ReadmeMdConfigFileOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('ReadmeMdConfigFileOutputPlugin', {outputFileName: 'README.md'}) + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {readmePrompts} = ctx.collectedInputContext + + if (readmePrompts == null || readmePrompts.length === 0) return results + + for (const readme of readmePrompts) { + const {targetDir} = readme + const outputFileName = resolveOutputFileName(readme.fileKind) + const filePath = path.join(targetDir.path, outputFileName) + + results.push({ + pathKind: FilePathKind.Relative, + path: filePath, + basePath: targetDir.basePath, + getDirectoryName: () => targetDir.getDirectoryName(), + getAbsolutePath: () => path.join(targetDir.basePath, filePath) + }) + } + + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {readmePrompts} = ctx.collectedInputContext + + if (readmePrompts?.length !== 0) return true + + this.log.debug('skipped', {reason: 'no README prompts to write'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + const {readmePrompts} = ctx.collectedInputContext + + if (readmePrompts == null || readmePrompts.length === 0) return {files: fileResults, dirs: dirResults} + + for (const readme of readmePrompts) { + const result = await this.writeReadmeFile(ctx, readme) + fileResults.push(result) + } + + return {files: fileResults, dirs: dirResults} + } + + private async writeReadmeFile( + ctx: OutputWriteContext, + readme: {projectName: string, targetDir: RelativePath, content: unknown, isRoot: boolean, fileKind?: ReadmeFileKind} + ): Promise { + const {targetDir} = readme + const outputFileName = resolveOutputFileName(readme.fileKind) + const filePath = path.join(targetDir.path, outputFileName) + const fullPath = path.join(targetDir.basePath, filePath) + const content = readme.content as string + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: filePath, + basePath: targetDir.basePath, + getDirectoryName: () => targetDir.getDirectoryName(), + getAbsolutePath: () => fullPath + } + + const label = readme.isRoot + ? `project:${readme.projectName}/${outputFileName}` + : `project:${readme.projectName}/${targetDir.path}/${outputFileName}` + + if (ctx.dryRun === true) { // Dry-run mode: log without writing + this.log.trace({action: 'dryRun', type: 'readme', path: fullPath, label}) + return {path: relativePath, success: true, skipped: false} + } + + try { // Actual write operation + const dir = path.dirname(fullPath) // Ensure target directory exists + if (!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true}) + + fs.writeFileSync(fullPath, content, 'utf8') + this.log.trace({action: 'write', type: 'readme', 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: 'readme', path: fullPath, label, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } +} diff --git a/cli/src/plugins/plugin-readme/index.ts b/cli/src/plugins/plugin-readme/index.ts new file mode 100644 index 00000000..e299d8c0 --- /dev/null +++ b/cli/src/plugins/plugin-readme/index.ts @@ -0,0 +1,3 @@ +export { + ReadmeMdConfigFileOutputPlugin +} from './ReadmeMdConfigFileOutputPlugin' diff --git a/cli/src/plugins/plugin-shared/AbstractPlugin.ts b/cli/src/plugins/plugin-shared/AbstractPlugin.ts new file mode 100644 index 00000000..cd9f2b1c --- /dev/null +++ b/cli/src/plugins/plugin-shared/AbstractPlugin.ts @@ -0,0 +1,26 @@ +import type {ILogger} from './log' +import type {PluginKind} from './types/Enums' +import type {Plugin} from './types/PluginTypes' + +import {createLogger} from './log' + +export abstract class AbstractPlugin implements Plugin { + readonly type: T + + readonly name: string + + private _log?: ILogger + + get log(): ILogger { + this._log ??= createLogger(this.name) + return this._log + } + + readonly dependsOn?: readonly string[] + + protected constructor(name: string, type: T, dependsOn?: readonly string[]) { + this.name = name + this.type = type + if (dependsOn != null) this.dependsOn = dependsOn + } +} diff --git a/cli/src/plugins/plugin-shared/PluginNames.ts b/cli/src/plugins/plugin-shared/PluginNames.ts new file mode 100644 index 00000000..8bc93739 --- /dev/null +++ b/cli/src/plugins/plugin-shared/PluginNames.ts @@ -0,0 +1,24 @@ +export const PLUGIN_NAMES = { + AgentsOutput: 'AgentsOutputPlugin', + GeminiCLIOutput: 'GeminiCLIOutputPlugin', + CursorOutput: 'CursorOutputPlugin', + WindsurfOutput: 'WindsurfOutputPlugin', + ClaudeCodeCLIOutput: 'ClaudeCodeCLIOutputPlugin', + KiroIDEOutput: 'KiroCLIOutputPlugin', + OpencodeCLIOutput: 'OpencodeCLIOutputPlugin', + OpenAICodexCLIOutput: 'CodexCLIOutputPlugin', + DroidCLIOutput: 'DroidCLIOutputPlugin', + WarpIDEOutput: 'WarpIDEOutputPlugin', + TraeIDEOutput: 'TraeIDEOutputPlugin', + QoderIDEOutput: 'QoderIDEPluginOutputPlugin', + JetBrainsCodeStyleOutput: 'JetBrainsIDECodeStyleConfigOutputPlugin', + JetBrainsAICodexOutput: 'JetBrainsAIAssistantCodexOutputPlugin', + AgentSkillsCompactOutput: 'GenericSkillsOutputPlugin', + GitExcludeOutput: 'GitExcludeOutputPlugin', + ReadmeOutput: 'ReadmeMdConfigFileOutputPlugin', + VSCodeOutput: 'VisualStudioCodeIDEConfigOutputPlugin', + EditorConfigOutput: 'EditorConfigOutputPlugin', + AntigravityOutput: 'AntigravityOutputPlugin' +} as const + +export type PluginName = (typeof PLUGIN_NAMES)[keyof typeof PLUGIN_NAMES] diff --git a/cli/src/plugins/plugin-shared/constants.ts b/cli/src/plugins/plugin-shared/constants.ts new file mode 100644 index 00000000..d7e089cb --- /dev/null +++ b/cli/src/plugins/plugin-shared/constants.ts @@ -0,0 +1,11 @@ +import type {UserConfigFile} from './types/ConfigTypes.schema' +import {bundles, getDefaultConfigContent} from '@truenine/init-bundle' + +export const PathPlaceholders = { + USER_HOME: '~', + WORKSPACE: '$WORKSPACE' +} as const + +type DefaultUserConfig = Readonly>> // Default user config type +const _bundleContent = bundles['public/tnmsc.example.json']?.content ?? getDefaultConfigContent() +export const DEFAULT_USER_CONFIG = JSON.parse(_bundleContent) as DefaultUserConfig // Imported from @truenine/init-bundle package diff --git a/cli/src/plugins/plugin-shared/index.ts b/cli/src/plugins/plugin-shared/index.ts new file mode 100644 index 00000000..3b272060 --- /dev/null +++ b/cli/src/plugins/plugin-shared/index.ts @@ -0,0 +1,23 @@ +export { + AbstractPlugin +} from './AbstractPlugin' +export { + DEFAULT_USER_CONFIG, + PathPlaceholders +} from './constants' +export { + createLogger, + getGlobalLogLevel, + setGlobalLogLevel +} from './log' +export type { + ILogger, + LogLevel +} from './log' +export { + PLUGIN_NAMES +} from './PluginNames' +export type { + PluginName +} from './PluginNames' +export * from './types' diff --git a/cli/src/plugins/plugin-shared/log.ts b/cli/src/plugins/plugin-shared/log.ts new file mode 100644 index 00000000..39aa1709 --- /dev/null +++ b/cli/src/plugins/plugin-shared/log.ts @@ -0,0 +1,9 @@ +export { + createLogger, + getGlobalLogLevel, + setGlobalLogLevel +} from '@truenine/logger' +export type { + ILogger, + LogLevel +} from '@truenine/logger' diff --git a/cli/src/plugins/plugin-shared/testing/index.ts b/cli/src/plugins/plugin-shared/testing/index.ts new file mode 100644 index 00000000..d7887558 --- /dev/null +++ b/cli/src/plugins/plugin-shared/testing/index.ts @@ -0,0 +1,65 @@ +import type {RelativePath} from '../types/FileSystemTypes' +import type {Project, RulePrompt} from '../types/InputTypes' +import {FilePathKind, NamingCaseKind, PromptKind} from '../types/Enums' + +export function createMockRulePrompt( + series: string, + ruleName: string, + seriName: string | undefined, + scope: 'global' | 'project' = 'project' +): RulePrompt { + const content = '# Rule body' + const base = { + type: PromptKind.Rule, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: { + pathKind: FilePathKind.Relative, + path: '.', + basePath: '', + getDirectoryName: () => '.', + getAbsolutePath: () => '.' + }, + markdownContents: [], + yamlFrontMatter: { + description: 'Test rule', + globs: ['**/*.ts'], + namingCase: NamingCaseKind.KebabCase + }, + series, + ruleName, + globs: ['**/*.ts'], + scope + } + + return seriName != null + ? {...base, seriName} as RulePrompt + : base as RulePrompt +} + +export function createMockProject( + name: string, + basePath: string, + projectPath: string, + projectConfig?: unknown +): Project { + return { + name, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: projectPath, + basePath, + getDirectoryName: () => name, + getAbsolutePath: () => `${basePath}/${projectPath}` + }, + ...projectConfig != null && {projectConfig: projectConfig as never} + } +} + +export function collectFileNames(results: RelativePath[]): string[] { + return results.map(r => { + const parts = r.path.split(/[/\\]/) + return parts.at(-1) ?? r.path + }) +} diff --git a/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.property.test.ts b/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.property.test.ts new file mode 100644 index 00000000..2f0ccff3 --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.property.test.ts @@ -0,0 +1,92 @@ +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' + +import {ZProjectConfig, ZTypeSeriesConfig} from './ConfigTypes.schema' + +describe('zProjectConfig property tests', () => { // Property 7: Zod schema round-trip. Validates: Requirement 1.5 + const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // alphanumeric series names + .filter(s => /^[\w-]+$/.test(s) && s !== '__proto__' && s !== 'constructor' && s !== 'prototype') + + const includeSeriesArb = fc.option( // optional string[] + fc.array(seriesNameArb, {minLength: 0, maxLength: 5}), + {nil: void 0} + ) + + const subSeriesArb = fc.option( // optional Record + fc.dictionary( + seriesNameArb, + fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), + {minKeys: 0, maxKeys: 3} + ), + {nil: void 0} + ) + + function stripUndefined(obj: Record): Record { // strip undefined to match Zod output + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + if (value !== void 0) result[key] = value + } + return result + } + + const typeSeriesConfigArb = fc.option( // optional TypeSeriesConfig + fc.record({ + includeSeries: includeSeriesArb, + subSeries: subSeriesArb + }).map(obj => stripUndefined(obj)), + {nil: void 0} + ) + + const projectConfigArb = fc.record({ // valid ProjectConfig (no mcp for simplicity) + includeSeries: includeSeriesArb, + subSeries: subSeriesArb, + rules: typeSeriesConfigArb, + skills: typeSeriesConfigArb, + subAgents: typeSeriesConfigArb, + commands: typeSeriesConfigArb + }).map(obj => stripUndefined(obj)) + + it('property 7: round-trip through JSON serialization preserves equivalence', () => { // Validates: Requirement 1.5 + fc.assert( + fc.property( + projectConfigArb, + config => { + const json = JSON.stringify(config) + const parsed = ZProjectConfig.parse(JSON.parse(json)) + expect(parsed).toEqual(config) + } + ), + {numRuns: 200} + ) + }) + + it('property 7: rejects configurations with incorrect includeSeries types', () => { // Validates: Requirement 1.5 + fc.assert( + fc.property( + fc.oneof( + fc.integer(), + fc.boolean(), + fc.constant('not-an-array') + ), + invalidValue => { + expect(() => ZProjectConfig.parse({includeSeries: invalidValue})).toThrow() + } + ), + {numRuns: 50} + ) + }) + + it('property 7: ZTypeSeriesConfig round-trip through JSON serialization', () => { // Validates: Requirement 1.4 + fc.assert( + fc.property( + typeSeriesConfigArb.filter((v): v is Record => v !== void 0), + config => { + const json = JSON.stringify(config) + const parsed = ZTypeSeriesConfig.parse(JSON.parse(json)) + expect(parsed).toEqual(config) + } + ), + {numRuns: 200} + ) + }) +}) diff --git a/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts b/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts new file mode 100644 index 00000000..33925197 --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts @@ -0,0 +1,122 @@ +import {z} from 'zod/v3' + +/** + * Zod schema for a source/dist path pair. + * Both paths are relative to the shadow source project root. + */ +export const ZShadowSourceProjectDirPair = z.object({ + /** Source path (human-authored .cn.mdx files) */ + src: z.string(), + /** Output/compiled path (read by input plugins) */ + dist: z.string() +}) + +/** + * Zod schema for the shadow source project configuration. + * All paths are relative to `/`. + */ +export const ZShadowSourceProjectConfig = z.object({ + name: z.string(), + skill: ZShadowSourceProjectDirPair, + fastCommand: ZShadowSourceProjectDirPair, + subAgent: ZShadowSourceProjectDirPair, + rule: ZShadowSourceProjectDirPair, + globalMemory: ZShadowSourceProjectDirPair, + workspaceMemory: ZShadowSourceProjectDirPair, + project: ZShadowSourceProjectDirPair +}) + +/** + * Zod schema for per-plugin fast command series override options + */ +export const ZFastCommandSeriesPluginOverride = z.object({ + includeSeriesPrefix: z.boolean().optional(), + seriesSeparator: z.string().optional() +}) + +/** + * Zod schema for fast command series configuration options + */ +export const ZFastCommandSeriesOptions = z.object({ + includeSeriesPrefix: z.boolean().optional(), + pluginOverrides: z.record(z.string(), ZFastCommandSeriesPluginOverride).optional() +}) + +/** + * Zod schema for user profile information + */ +export const ZUserProfile = z.object({ + name: z.string().optional(), + username: z.string().optional(), + gender: z.string().optional(), + birthday: z.string().optional() +}).catchall(z.unknown()) + +/** + * Zod schema for the user configuration file (.tnmsc.json). + * All fields are optional — missing fields use default values. + */ +export const ZUserConfigFile = z.object({ + version: z.string().optional(), + workspaceDir: z.string().optional(), + shadowSourceProject: ZShadowSourceProjectConfig.optional(), + logLevel: z.enum(['trace', 'debug', 'info', 'warn', 'error']).optional(), + fastCommandSeriesOptions: ZFastCommandSeriesOptions.optional(), + profile: ZUserProfile.optional() +}) + +/** + * Zod schema for MCP project config + */ +export const ZMcpProjectConfig = z.object({names: z.array(z.string()).optional()}) + +/** + * Zod schema for per-type series filtering configuration. + * Shared by all four prompt type sections (rules, skills, subAgents, commands). + */ +export const ZTypeSeriesConfig = z.object({ + includeSeries: z.array(z.string()).optional(), + subSeries: z.record(z.string(), z.array(z.string())).optional() +}) + +/** + * Zod schema for project config + */ +export const ZProjectConfig = z.object({ + mcp: ZMcpProjectConfig.optional(), + includeSeries: z.array(z.string()).optional(), + subSeries: z.record(z.string(), z.array(z.string())).optional(), + rules: ZTypeSeriesConfig.optional(), + skills: ZTypeSeriesConfig.optional(), + subAgents: ZTypeSeriesConfig.optional(), + commands: ZTypeSeriesConfig.optional() +}) + +/** + * Zod schema for ConfigLoader options + */ +export const ZConfigLoaderOptions = z.object({ + configFileName: z.string().optional(), + searchPaths: z.array(z.string()).optional(), + searchCwd: z.boolean().optional(), + searchGlobal: z.boolean().optional() +}) + +export type ShadowSourceProjectDirPair = z.infer +export type ShadowSourceProjectConfig = z.infer +export type FastCommandSeriesPluginOverride = z.infer +export type FastCommandSeriesOptions = z.infer +export type UserConfigFile = z.infer +export type McpProjectConfig = z.infer +export type TypeSeriesConfig = z.infer +export type ProjectConfig = z.infer +export type ConfigLoaderOptions = z.infer + +/** + * Result of loading a config file + */ +export interface ConfigLoadResult { + readonly config: UserConfigFile + readonly source: string | null + readonly found: boolean +} diff --git a/cli/src/plugins/plugin-shared/types/Enums.ts b/cli/src/plugins/plugin-shared/types/Enums.ts new file mode 100644 index 00000000..6b6db7b3 --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/Enums.ts @@ -0,0 +1,75 @@ +export enum PluginKind { + Input = 'Input', + Output = 'Output' +} + +export enum PromptKind { + GlobalMemory = 'GlobalMemory', + ProjectRootMemory = 'ProjectRootMemory', + ProjectChildrenMemory = 'ProjectChildrenMemory', + FastCommand = 'FastCommand', + SubAgent = 'SubAgent', + Skill = 'Skill', + SkillChildDoc = 'SkillChildDoc', + SkillResource = 'SkillResource', + SkillMcpConfig = 'SkillMcpConfig', + Readme = 'Readme', + Rule = 'Rule' +} + +/** + * Scope for rule application + */ +export type RuleScope = 'project' | 'global' + +export enum ClaudeCodeCLISubAgentColors { + Red = 'Red', + Green = 'Green', + Blue = 'Blue', + Yellow = 'Yellow' +} + +/** + * Tools callable by AI Agent + */ +export enum CodingAgentTools { + Read = 'Read', + Write = 'Write', + Edit = 'Edit', + Grep = 'Grep' +} + +/** + * Naming convention + */ +export enum NamingCaseKind { + CamelCase = 'CamelCase', + PascalCase = 'PascalCase', + SnakeCase = 'SnakeCase', + KebabCase = 'KebabCase', + UpperCase = 'UpperCase', + LowerCase = 'LowerCase', + Original = 'Original' +} + +export enum GlobalConfigDirectoryType { + UserHome = 'UserHome', + External = 'External' +} + +/** + * Directory path kind + */ +export enum FilePathKind { + Relative = 'Relative', + Absolute = 'Absolute', + Root = 'Root' +} + +export enum IDEKind { + VSCode = 'VSCode', + IntellijIDEA = 'IntellijIDEA', + Git = 'Git', + EditorConfig = 'EditorConfig', + Original = 'Original' +} diff --git a/cli/src/plugins/plugin-shared/types/Errors.ts b/cli/src/plugins/plugin-shared/types/Errors.ts new file mode 100644 index 00000000..1379295d --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/Errors.ts @@ -0,0 +1,40 @@ +/** + * Error thrown when a circular dependency is detected in the plugin graph. + */ +export class CircularDependencyError extends Error { + constructor(public readonly cycle: string[]) { + super(`Circular dependency detected: ${cycle.join(' -> ')}`) + this.name = 'CircularDependencyError' + } +} + +/** + * Error thrown when a plugin depends on a non-existent plugin. + */ +export class MissingDependencyError extends Error { + constructor( + public readonly pluginName: string, + public readonly missingDependency: string + ) { + super(`Plugin "${pluginName}" depends on non-existent plugin "${missingDependency}"`) + this.name = 'MissingDependencyError' + } +} + +/** + * Configuration validation error + * Error thrown when configuration file contains invalid fields + */ +export class ConfigValidationError extends Error { + constructor( + readonly field: string, + readonly reason: string, + readonly filePath?: string + ) { + const msg = filePath != null && filePath.length > 0 + ? `Invalid configuration field "${field}": ${reason} (file: ${filePath})` + : `Invalid configuration field "${field}": ${reason}` + super(msg) + this.name = 'ConfigValidationError' + } +} diff --git a/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts b/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts new file mode 100644 index 00000000..361e0c60 --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts @@ -0,0 +1,213 @@ +/** + * Export metadata types for MDX files + * These interfaces define the expected structure of export statements in MDX files + * that are used as front matter metadata. + * + * @module ExportMetadataTypes + */ + +import type {CodingAgentTools, NamingCaseKind, RuleScope} from './Enums' + +/** + * Base export metadata interface + * All export metadata types should extend this + */ +export interface BaseExportMetadata { + readonly namingCase?: NamingCaseKind +} + +export interface SkillExportMetadata extends BaseExportMetadata { + readonly name: string + readonly description: string + readonly keywords?: readonly string[] + readonly enabled?: boolean + readonly displayName?: string + readonly author?: string + readonly version?: string + readonly allowTools?: readonly (CodingAgentTools | string)[] +} + +export interface FastCommandExportMetadata extends BaseExportMetadata { + readonly description?: string + readonly argumentHint?: string + readonly allowTools?: readonly (CodingAgentTools | string)[] + readonly globalOnly?: boolean +} + +export interface RuleExportMetadata extends BaseExportMetadata { + readonly globs: readonly string[] + readonly description: string + readonly scope?: RuleScope + readonly seriName?: string +} + +export interface SubAgentExportMetadata extends BaseExportMetadata { + readonly name: string + readonly description: string + readonly role?: string + readonly model?: string + readonly color?: string + readonly argumentHint?: string + readonly allowTools?: readonly (CodingAgentTools | string)[] +} + +/** + * Metadata validation result + */ +export interface MetadataValidationResult { + readonly valid: boolean + readonly errors: readonly string[] + readonly warnings: readonly string[] +} + +/** + * Options for metadata validation + */ +export interface ValidateMetadataOptions { + readonly requiredFields: readonly (keyof T)[] + readonly optionalDefaults?: Partial + readonly filePath?: string | undefined +} + +export function validateExportMetadata( + metadata: Record, + options: ValidateMetadataOptions +): MetadataValidationResult { + const {requiredFields, optionalDefaults, filePath} = options + const errors: string[] = [] + const warnings: string[] = [] + + for (const field of requiredFields) { // Check required fields + const fieldName = String(field) + if (!(fieldName in metadata) || metadata[fieldName] == null) { + const errorMsg = filePath != null + ? `Missing required field "${fieldName}" in ${filePath}` + : `Missing required field "${fieldName}"` + errors.push(errorMsg) + } + } + + if (optionalDefaults != null) { // Check optional fields and record warnings for defaults + for (const [key, defaultValue] of Object.entries(optionalDefaults)) { + if (!(key in metadata) || metadata[key] == null) { + const warningMsg = filePath != null + ? `Using default value for optional field "${key}": ${JSON.stringify(defaultValue)} in ${filePath}` + : `Using default value for optional field "${key}": ${JSON.stringify(defaultValue)}` + warnings.push(warningMsg) + } + } + } + + return { + valid: errors.length === 0, + errors, + warnings + } +} + +/** + * Validate skill export metadata + * + * @param metadata - The metadata object to validate + * @param filePath - Optional file path for error messages + * @returns Validation result + */ +export function validateSkillMetadata( + metadata: Record, + filePath?: string +): MetadataValidationResult { + return validateExportMetadata(metadata, { + requiredFields: ['name', 'description'], + optionalDefaults: { + enabled: true, + keywords: [] + }, + filePath + }) +} + +/** + * Validate fast command export metadata + * + * @param metadata - The metadata object to validate + * @param filePath - Optional file path for error messages + * @returns Validation result + */ +export function validateFastCommandMetadata( + metadata: Record, + filePath?: string +): MetadataValidationResult { + return validateExportMetadata(metadata, { // description is optional (can come from YAML or be omitted) // FastCommand has no required fields from export metadata + requiredFields: [], + optionalDefaults: {}, + filePath + }) +} + +/** + * Validate sub-agent export metadata + * + * @param metadata - The metadata object to validate + * @param filePath - Optional file path for error messages + * @returns Validation result + */ +export function validateSubAgentMetadata( + metadata: Record, + filePath?: string +): MetadataValidationResult { + return validateExportMetadata(metadata, { + requiredFields: ['name', 'description'], + optionalDefaults: {}, + filePath + }) +} + +/** + * 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, seriName} = 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}`) + + if (seriName != null && typeof seriName !== 'string') errors.push(`Field "seriName" must be a string${prefix}`) + + return {valid: errors.length === 0, errors, warnings} +} + +/** + * Apply default values to metadata + * + * @param metadata - The metadata object + * @param defaults - Default values to apply + * @returns Metadata with defaults applied + */ +export function applyMetadataDefaults( + metadata: Record, + defaults: Partial +): T { + const result = {...metadata} + + for (const [key, defaultValue] of Object.entries(defaults)) { + if (!(key in result) || result[key] == null) result[key] = defaultValue + } + + return result as T +} diff --git a/cli/src/plugins/plugin-shared/types/FileSystemTypes.ts b/cli/src/plugins/plugin-shared/types/FileSystemTypes.ts new file mode 100644 index 00000000..8528424e --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/FileSystemTypes.ts @@ -0,0 +1,37 @@ +import type {FilePathKind} from './Enums' + +/** + * Common directory representation + */ +export interface Path { + readonly pathKind: K + readonly path: string + readonly getDirectoryName: () => string +} + +/** + * Relative path directory + */ +export interface RelativePath extends Path { + readonly basePath: string + getAbsolutePath: () => string +} + +/** + * Absolute path directory + */ +export type AbsolutePath = Path + +export type RootPath = Path + +export interface FileContent< + C = unknown, + FK extends FilePathKind = FilePathKind.Relative, + F extends Path = RelativePath +> { + content: C + length: number + filePathKind: FK + dir: F + charsetEncoding?: BufferEncoding +} diff --git a/cli/src/plugins/plugin-shared/types/InputTypes.ts b/cli/src/plugins/plugin-shared/types/InputTypes.ts new file mode 100644 index 00000000..9f67eed8 --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/InputTypes.ts @@ -0,0 +1,417 @@ +import type {ProjectConfig} from './ConfigTypes.schema' +import type { + FilePathKind, + IDEKind, + PromptKind, + RuleScope +} from './Enums' +import type {FileContent, Path, RelativePath} from './FileSystemTypes' +import type { + FastCommandYAMLFrontMatter, + GlobalMemoryPrompt, + ProjectChildrenMemoryPrompt, + ProjectRootMemoryPrompt, + Prompt, + RuleYAMLFrontMatter, + SkillYAMLFrontMatter, + SubAgentYAMLFrontMatter +} from './PromptTypes' + +export interface Project { + readonly name?: string + readonly dirFromWorkspacePath?: RelativePath + readonly rootMemoryPrompt?: ProjectRootMemoryPrompt + readonly childMemoryPrompts?: readonly ProjectChildrenMemoryPrompt[] + readonly isPromptSourceProject?: boolean + readonly projectConfig?: ProjectConfig +} + +export interface Workspace { + readonly directory: Path + readonly projects: Project[] +} + +/** + * IDE configuration file + */ +export interface ProjectIDEConfigFile extends FileContent { + readonly type: I +} + +/** + * AI Agent ignore configuration file + */ +export interface AIAgentIgnoreConfigFile { + readonly fileName: string + readonly content: string +} + +/** + * All collected output information, provided to plugin system as input for output plugins + */ +export interface CollectedInputContext { + readonly workspace: Workspace + readonly vscodeConfigFiles?: readonly ProjectIDEConfigFile[] + readonly jetbrainsConfigFiles?: readonly ProjectIDEConfigFile[] + readonly editorConfigFiles?: readonly ProjectIDEConfigFile[] + 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 + readonly shadowGitExclude?: string + readonly shadowSourceProjectDir?: string + 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 seriName?: string | string[] | null + readonly rawMdxContent?: string +} + +/** + * Fast command prompt + */ +export interface FastCommandPrompt extends Prompt { + readonly type: PromptKind.FastCommand + readonly globalOnly?: true + readonly series?: string + readonly commandName: string + readonly seriName?: string | string[] | null + readonly rawMdxContent?: string +} + +/** + * Sub-agent prompt + */ +export interface SubAgentPrompt extends Prompt { + readonly type: PromptKind.SubAgent + readonly series?: string + readonly agentName: string + readonly seriName?: string | string[] | null + readonly rawMdxContent?: string +} + +/** + * Skill child document (.md files in skill directory or any subdirectory) + * Excludes skill.md which is the main skill file + */ +export interface SkillChildDoc extends Prompt { + readonly type: PromptKind.SkillChildDoc + readonly relativePath: string +} + +/** + * Resource content encoding type + */ +export type SkillResourceEncoding = 'text' | 'base64' + +/** + * Resource category for classification + * + * Categories: + * - code: .kt, .java, .py, .ts, .js, .go, .rs, etc. + * - data: .sql, .json, .xml, .yaml, .csv, etc. + * - document: .txt, .rtf, .docx, .pdf, etc. + * - config: .ini, .conf, .properties, etc. + * - script: .sh, .bash, .ps1, .bat, etc. + * - image: .png, .jpg, .gif, .svg, .webp, etc. + * - binary: .exe, .dll, .so, .wasm, etc. + * - other: anything else + */ +export type SkillResourceCategory + = | 'code' + | 'data' + | 'document' + | 'config' + | 'script' + | 'image' + | 'binary' + | 'other' + +/** + * Skill resource file for AI on-demand access + * Any non-.md file in skill directory or subdirectories + * + * Supports: + * - Code files: .kt, .java, .py, .ts, .js, .go, .rs, .c, .cpp, etc. + * - Data files: .sql, .json, .xml, .yaml, .csv, etc. + * - Documents: .txt, .rtf, .docx, .pdf, etc. + * - Config files: .ini, .conf, .properties, etc. + * - Scripts: .sh, .bash, .ps1, .bat, etc. + * - Images: .png, .jpg, .gif, .svg, .webp, etc. + * - Binary files: .exe, .dll, .wasm, etc. + */ +export interface SkillResource { + readonly type: PromptKind.SkillResource + readonly extension: string + readonly fileName: string + readonly relativePath: string + readonly content: string + readonly encoding: SkillResourceEncoding + readonly category: SkillResourceCategory + readonly length: number + readonly mimeType?: string +} + +/** + * Text file extensions that should be read as UTF-8 + */ +export const SKILL_RESOURCE_TEXT_EXTENSIONS = [ + '.kt', // Code files + '.java', + '.py', + '.pyi', + '.pyx', + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.go', + '.rs', + '.c', + '.cpp', + '.cc', + '.h', + '.hpp', + '.hxx', + '.cs', + '.fs', + '.fsx', + '.vb', + '.rb', + '.php', + '.swift', + '.scala', + '.groovy', + '.lua', + '.r', + '.R', + '.jl', + '.ex', + '.exs', + '.erl', + '.clj', + '.cljs', + '.hs', + '.ml', + '.mli', + '.nim', + '.zig', + '.v', + '.dart', + '.vue', + '.svelte', + '.sql', // Data files + '.json', + '.jsonc', + '.json5', + '.xml', + '.xsd', + '.xsl', + '.xslt', + '.yaml', + '.yml', + '.toml', + '.csv', + '.tsv', + '.graphql', + '.gql', + '.proto', + '.txt', // Document files + '.text', + '.rtf', + '.log', + '.ini', // Config files + '.conf', + '.cfg', + '.config', + '.properties', + '.env', + '.envrc', + '.editorconfig', + '.gitignore', + '.gitattributes', + '.npmrc', + '.nvmrc', + '.npmignore', + '.eslintrc', + '.prettierrc', + '.stylelintrc', + '.babelrc', + '.browserslistrc', + '.sh', // Script files + '.bash', + '.zsh', + '.fish', + '.ps1', + '.psm1', + '.psd1', + '.bat', + '.cmd', + '.html', // Web files + '.htm', + '.xhtml', + '.css', + '.scss', + '.sass', + '.less', + '.styl', + '.svg', + '.ejs', // Template files + '.hbs', + '.mustache', + '.pug', + '.jade', + '.jinja', + '.jinja2', + '.j2', + '.erb', + '.haml', + '.slim', + '.d.ts', // Declaration files + '.d.mts', + '.d.cts', + '.diff', // Other text formats + '.patch', + '.asm', + '.s', + '.makefile', + '.mk', + '.dockerfile', + '.tf', + '.tfvars', // Terraform + '.prisma', // Prisma + '.mdx' // MDX (but not .md which is handled separately) +] as const + +/** + * Binary file extensions that should be read as base64 + */ +export const SKILL_RESOURCE_BINARY_EXTENSIONS = [ + '.docx', // Documents + '.doc', + '.xlsx', + '.xls', + '.pptx', + '.ppt', + '.pdf', + '.odt', + '.ods', + '.odp', + '.png', // Images + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.ico', + '.bmp', + '.tiff', + '.zip', // Archives + '.tar', + '.gz', + '.bz2', + '.7z', + '.rar', + '.pyd', // Compiled + '.pyc', + '.pyo', + '.class', + '.jar', + '.war', + '.dll', + '.so', + '.dylib', + '.exe', + '.bin', + '.wasm', + '.ttf', // Fonts + '.otf', + '.woff', + '.woff2', + '.eot', + '.mp3', // Audio/Video (usually not needed but for completeness) + '.wav', + '.ogg', + '.mp4', + '.webm', + '.db', // Database + '.sqlite', + '.sqlite3' +] as const + +export type SkillResourceTextExtension = typeof SKILL_RESOURCE_TEXT_EXTENSIONS[number] +export type SkillResourceBinaryExtension = typeof SKILL_RESOURCE_BINARY_EXTENSIONS[number] + +/** + * MCP server configuration entry + */ +export interface McpServerConfig { + readonly command: string + readonly args?: readonly string[] + readonly env?: Readonly> + readonly disabled?: boolean + readonly autoApprove?: readonly string[] +} + +/** + * Skill MCP configuration (mcp.json) + * - Kiro: supports per-power MCP configuration natively + * - Others: may support lazy loading in the future + */ +export interface SkillMcpConfig { + readonly type: PromptKind.SkillMcpConfig + readonly mcpServers: Readonly> + readonly rawContent: string +} + +export interface SkillPrompt extends Prompt { + readonly type: PromptKind.Skill + readonly dir: RelativePath + readonly yamlFrontMatter: SkillYAMLFrontMatter + readonly mcpConfig?: SkillMcpConfig + readonly childDocs?: SkillChildDoc[] + readonly resources?: SkillResource[] + readonly seriName?: string | string[] | null +} + +/** + * Readme-family source file kind + * + * - Readme: rdm.mdx → README.md + * - CodeOfConduct: coc.mdx → CODE_OF_CONDUCT.md + * - Security: security.mdx → SECURITY.md + */ +export type ReadmeFileKind = 'Readme' | 'CodeOfConduct' | 'Security' + +/** + * Mapping from ReadmeFileKind to source/output file names + */ +export const README_FILE_KIND_MAP: Readonly> = { + Readme: {src: 'rdm.mdx', out: 'README.md'}, + CodeOfConduct: {src: 'coc.mdx', out: 'CODE_OF_CONDUCT.md'}, + Security: {src: 'security.mdx', out: 'SECURITY.md'} +} + +/** + * README-family prompt data structure (README.md, CODE_OF_CONDUCT.md, SECURITY.md) + */ +export interface ReadmePrompt extends Prompt { + readonly type: PromptKind.Readme + readonly projectName: string + readonly targetDir: RelativePath + readonly isRoot: boolean + readonly fileKind: ReadmeFileKind +} diff --git a/cli/src/plugins/plugin-shared/types/OutputTypes.ts b/cli/src/plugins/plugin-shared/types/OutputTypes.ts new file mode 100644 index 00000000..1e21c3c7 --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/OutputTypes.ts @@ -0,0 +1,24 @@ +import type {GlobalConfigDirectoryType} from './Enums' +import type {AbsolutePath, RelativePath} from './FileSystemTypes' + +/** + * Global configuration based on user_home root directory + */ +export interface GlobalConfigDirectoryInUserHome { + readonly type: K + readonly directory: RelativePath +} + +/** + * Special, absolute path global memory prompt + */ +export interface GlobalConfigDirectoryInOther { + readonly type: K + readonly directory: AbsolutePath +} + +export type GlobalConfigDirectory = GlobalConfigDirectoryInUserHome | GlobalConfigDirectoryInOther + +export interface Target { + +} diff --git a/cli/src/plugins/plugin-shared/types/PluginTypes.ts b/cli/src/plugins/plugin-shared/types/PluginTypes.ts new file mode 100644 index 00000000..0e2cf619 --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/PluginTypes.ts @@ -0,0 +1,390 @@ +import type {ILogger} from '@truenine/logger' +import type {MdxGlobalScope} from '@truenine/md-compiler/globals' +import type {FastCommandSeriesOptions, ShadowSourceProjectConfig} from './ConfigTypes.schema' +import type {PluginKind} from './Enums' +import type {RelativePath} from './FileSystemTypes' +import type { + CollectedInputContext, + Project +} from './InputTypes' + +/** + * Opaque type for ScopeRegistry. + * Concrete implementation lives in plugin-input-shared. + */ +export interface ScopeRegistryLike { + resolve: (expression: string) => string +} + +export interface Plugin { + readonly type: T + readonly name: string + readonly log: ILogger + readonly dependsOn?: readonly string[] +} + +export interface PluginContext { + logger: ILogger + fs: typeof import('node:fs') + path: typeof import('node:path') + glob: typeof import('fast-glob') +} + +export interface InputPluginContext extends PluginContext { + readonly userConfigOptions: Required + readonly dependencyContext: Partial + + readonly globalScope?: MdxGlobalScope + + readonly scopeRegistry?: ScopeRegistryLike +} + +export interface InputPlugin extends Plugin { + collect: (ctx: InputPluginContext) => Partial | Promise> +} + +/** + * Plugin that can enhance projects after all projects are collected. + * This is useful for plugins that need to add data to projects + * that were collected by other plugins. + */ +export interface ProjectEnhancerPlugin extends InputPlugin { + enhanceProjects: (ctx: InputPluginContext, projects: readonly Project[]) => Project[] +} + +/** + * Context for output plugin operations + */ +export interface OutputPluginContext extends PluginContext { + readonly collectedInputContext: CollectedInputContext + readonly pluginOptions?: PluginOptions +} + +/** + * Context for output cleaning operations + */ +export interface OutputCleanContext extends OutputPluginContext { + readonly dryRun?: boolean +} + +/** + * Context for output writing operations + */ +export interface OutputWriteContext extends OutputPluginContext { + readonly dryRun?: boolean + + readonly registeredPluginNames?: readonly string[] +} + +/** + * Result of a single write operation + */ +export interface WriteResult { + readonly path: RelativePath + readonly success: boolean + readonly skipped?: boolean + readonly error?: Error +} + +/** + * Result of executing a side effect. + * Used for both write and clean effects. + */ +export interface EffectResult { + /** Whether the effect executed successfully */ + readonly success: boolean + /** Error details if the effect failed */ + readonly error?: Error + /** Description of what the effect did (for logging) */ + readonly description?: string +} + +/** + * Collected results from write operations + */ +export interface WriteResults { + readonly files: readonly WriteResult[] + readonly dirs: readonly WriteResult[] +} + +/** + * Awaitable type for sync/async flexibility + */ +export type Awaitable = T | Promise + +/** + * Handler function for write effects. + * Receives the write context and returns an effect result. + */ +export type WriteEffectHandler = (ctx: OutputWriteContext) => Awaitable + +/** + * Handler function for clean effects. + * Receives the clean context and returns an effect result. + */ +export type CleanEffectHandler = (ctx: OutputCleanContext) => Awaitable + +/** + * Result of executing an input effect. + * Used for preprocessing/cleaning input sources before collection. + */ +export interface InputEffectResult { + /** Whether the effect executed successfully */ + readonly success: boolean + /** Error details if the effect failed */ + readonly error?: Error + /** Description of what the effect did (for logging) */ + readonly description?: string + /** Files that were modified/created */ + readonly modifiedFiles?: readonly string[] + /** Files that were deleted */ + readonly deletedFiles?: readonly string[] +} + +/** + * Context provided to input effect handlers. + * Contains utilities and configuration for effect execution. + */ +export interface InputEffectContext { + /** Logger instance */ + readonly logger: ILogger + /** File system module */ + readonly fs: typeof import('node:fs') + /** Path module */ + readonly path: typeof import('node:path') + /** Glob module for file matching */ + readonly glob: typeof import('fast-glob') + /** Child process spawn function */ + readonly spawn: typeof import('node:child_process').spawn + /** User configuration options */ + readonly userConfigOptions: PluginOptions + /** Resolved workspace directory */ + readonly workspaceDir: string + /** Resolved shadow project directory */ + readonly shadowProjectDir: string + /** Whether running in dry-run mode */ + readonly dryRun?: boolean +} + +/** + * Handler function for input effects. + * Receives the effect context and returns an effect result. + */ +export type InputEffectHandler = (ctx: InputEffectContext) => Awaitable + +/** + * Registration entry for an input effect. + */ +export interface InputEffectRegistration { + /** Descriptive name for logging */ + readonly name: string + /** The effect handler function */ + readonly handler: InputEffectHandler + /** Priority for execution order (lower = earlier, default: 0) */ + readonly priority?: number +} + +/** + * Result of resolving base paths from plugin options. + */ +export interface ResolvedBasePaths { + /** The resolved workspace directory path */ + readonly workspaceDir: string + /** The resolved shadow project directory path */ + readonly shadowProjectDir: string +} + +/** + * Represents a registered scope entry from a plugin. + */ +export interface PluginScopeRegistration { + /** The namespace name (e.g., 'myPlugin') */ + readonly namespace: string + /** Key-value pairs registered under this namespace */ + readonly values: Record +} + +/** + * Registration entry for an effect. + */ +export interface EffectRegistration { + /** Descriptive name for logging */ + readonly name: string + /** The effect handler function */ + readonly handler: THandler +} + +/** + * Output plugin interface. + * Plugins directly implement lifecycle hooks as methods. + * All hooks support both sync and async implementations. + */ +export interface OutputPlugin extends Plugin { + registerProjectOutputDirs?: (ctx: OutputPluginContext) => Awaitable + + registerProjectOutputFiles?: (ctx: OutputPluginContext) => Awaitable + + registerGlobalOutputDirs?: (ctx: OutputPluginContext) => Awaitable + + registerGlobalOutputFiles?: (ctx: OutputPluginContext) => Awaitable + + canCleanProject?: (ctx: OutputCleanContext) => Awaitable + + canCleanGlobal?: (ctx: OutputCleanContext) => Awaitable + + onCleanComplete?: (ctx: OutputCleanContext) => Awaitable + + canWrite?: (ctx: OutputWriteContext) => Awaitable + + writeProjectOutputs?: (ctx: OutputWriteContext) => Awaitable + + writeGlobalOutputs?: (ctx: OutputWriteContext) => Awaitable + + onWriteComplete?: (ctx: OutputWriteContext, results: WriteResults) => Awaitable +} + +/** + * Collected outputs from all plugins. + * Used by the clean command to gather all artifacts for cleanup. + */ +export interface CollectedOutputs { + readonly projectDirs: readonly RelativePath[] + readonly projectFiles: readonly RelativePath[] + readonly globalDirs: readonly RelativePath[] + readonly globalFiles: readonly RelativePath[] +} + +/** + * Collect all outputs from all registered output plugins. + * This is the main entry point for the clean command. + */ +export async function collectAllPluginOutputs( + plugins: readonly OutputPlugin[], + ctx: OutputPluginContext +): Promise { + const projectDirs: RelativePath[] = [] + const projectFiles: RelativePath[] = [] + const globalDirs: RelativePath[] = [] + const globalFiles: RelativePath[] = [] + + for (const plugin of plugins) { + if (plugin.registerProjectOutputDirs) projectDirs.push(...await plugin.registerProjectOutputDirs(ctx)) + if (plugin.registerProjectOutputFiles) projectFiles.push(...await plugin.registerProjectOutputFiles(ctx)) + if (plugin.registerGlobalOutputDirs) globalDirs.push(...await plugin.registerGlobalOutputDirs(ctx)) + if (plugin.registerGlobalOutputFiles) globalFiles.push(...await plugin.registerGlobalOutputFiles(ctx)) + } + + return { + projectDirs, + projectFiles, + globalDirs, + globalFiles + } +} + +/** + * Result of checking if a plugin allows cleaning. + */ +export interface CleanPermission { + readonly project: boolean + readonly global: boolean +} + +/** + * Check if all plugins allow cleaning. + * Returns a map of plugin name to whether cleaning is allowed. + */ +export async function checkCanClean( + plugins: readonly OutputPlugin[], + ctx: OutputCleanContext +): Promise> { + const result = new Map() + + for (const plugin of plugins) { + result.set(plugin.name, {project: await plugin.canCleanProject?.(ctx) ?? true, global: await plugin.canCleanGlobal?.(ctx) ?? true}) + } + + return result +} + +/** + * Execute post-clean hooks for all plugins. + */ +export async function executeOnCleanComplete( + plugins: readonly OutputPlugin[], + ctx: OutputCleanContext +): Promise { + for (const plugin of plugins) await plugin.onCleanComplete?.(ctx) +} + +/** + * Result of checking if a plugin allows writing. + */ +export interface WritePermission { + readonly project: boolean + readonly global: boolean +} + +/** + * Check if all plugins allow writing. + * Returns a map of plugin name to whether writing is allowed. + */ +export async function checkCanWrite( + plugins: readonly OutputPlugin[], + ctx: OutputWriteContext +): Promise> { + const result = new Map() + + for (const plugin of plugins) { + const canWrite = await plugin.canWrite?.(ctx) ?? true + result.set(plugin.name, {project: canWrite, global: canWrite}) + } + + return result +} + +/** + * Execute write operations for all plugins. + * Respects dry-run mode in context. + */ +export async function executeWriteOutputs( + plugins: readonly OutputPlugin[], + ctx: OutputWriteContext +): Promise> { + const results = new Map() + + for (const plugin of plugins) { + const projectResults = await plugin.writeProjectOutputs?.(ctx) ?? {files: [], dirs: []} + const globalResults = await plugin.writeGlobalOutputs?.(ctx) ?? {files: [], dirs: []} + + const merged: WriteResults = { + files: [...projectResults.files, ...globalResults.files], + dirs: [...projectResults.dirs, ...globalResults.dirs] + } + + results.set(plugin.name, merged) + await plugin.onWriteComplete?.(ctx, merged) + } + + return results +} + +/** + * Configuration to be processed by plugin.config.ts + * Interpreted by plugin system as collection context + * Path placeholder `~` resolves to the user home directory. + * + * @see CollectedInputContext - Collected context + */ +export interface PluginOptions { + readonly version?: string + + readonly workspaceDir?: string + + readonly shadowSourceProject?: ShadowSourceProjectConfig + + readonly fastCommandSeriesOptions?: FastCommandSeriesOptions + + plugins?: Plugin[] + logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' +} diff --git a/cli/src/plugins/plugin-shared/types/PromptTypes.ts b/cli/src/plugins/plugin-shared/types/PromptTypes.ts new file mode 100644 index 00000000..8504237f --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/PromptTypes.ts @@ -0,0 +1,146 @@ +import type {Root, RootContent} from '@truenine/md-compiler' +import type {ClaudeCodeCLISubAgentColors, CodingAgentTools, FilePathKind, NamingCaseKind, PromptKind, RuleScope} from './Enums' +import type {FileContent, Path, RelativePath, RootPath} from './FileSystemTypes' +import type {GlobalConfigDirectory} from './OutputTypes' + +/** + * Prompt + */ +export interface Prompt< + T extends PromptKind = PromptKind, + Y extends YAMLFrontMatter = YAMLFrontMatter, + DK extends FilePathKind = FilePathKind.Relative, + D extends Path = RelativePath, + C = unknown +> extends FileContent { + readonly type: T + readonly yamlFrontMatter?: Y + readonly rawFrontMatter?: string + readonly markdownAst?: Root + readonly markdownContents: readonly RootContent[] + readonly dir: D +} + +export interface YAMLFrontMatter extends Record { + readonly namingCase: N +} + +export interface CommonYAMLFrontMatter extends YAMLFrontMatter { + readonly description: string +} + +export interface ToolAwareYAMLFrontMatter extends CommonYAMLFrontMatter { + readonly allowTools?: (CodingAgentTools | string)[] + readonly argumentHint?: string +} + +/** + * Memory prompt working on project root directory + */ +export interface ProjectRootMemoryPrompt extends Prompt< + PromptKind.ProjectRootMemory, + YAMLFrontMatter, + FilePathKind.Relative, + RootPath +> { + readonly type: PromptKind.ProjectRootMemory +} + +/** + * Memory prompt working on project subdirectory + */ +export interface ProjectChildrenMemoryPrompt extends Prompt { + readonly type: PromptKind.ProjectChildrenMemory + readonly workingChildDirectoryPath: RelativePath +} + +export interface SubAgentYAMLFrontMatter extends ToolAwareYAMLFrontMatter { + readonly name: string + readonly model?: string + readonly color?: ClaudeCodeCLISubAgentColors | string + readonly seriName?: string | string[] | null +} + +export interface FastCommandYAMLFrontMatter extends ToolAwareYAMLFrontMatter { + readonly seriName?: string | string[] | null +} // description, argumentHint, allowTools inherited from ToolAwareYAMLFrontMatter + +/** + * Base YAML front matter for all skill types + */ +export interface SkillsYAMLFrontMatter extends CommonYAMLFrontMatter { + readonly name: string +} + +export interface SkillYAMLFrontMatter extends SkillsYAMLFrontMatter { + readonly allowTools?: (CodingAgentTools | string)[] + readonly keywords?: readonly string[] + readonly displayName?: string + readonly author?: string + readonly version?: string + readonly seriName?: string | string[] | null +} + +/** + * Codex skill metadata field + * Follows Agent Skills specification: https://agentskills.io/specification + * + * The metadata field is an arbitrary key-value mapping for additional metadata. + * Common fields include displayName, version, author, keywords, etc. + */ +export interface CodexSkillMetadata { + readonly 'short-description'?: string + readonly 'displayName'?: string + readonly 'version'?: string + readonly 'author'?: string + readonly 'keywords'?: readonly string[] + readonly 'category'?: string + readonly 'repository'?: string + readonly [key: string]: unknown +} + +export interface CodexSkillYAMLFrontMatter extends SkillsYAMLFrontMatter { + readonly 'license'?: string + readonly 'compatibility'?: string + readonly 'metadata'?: CodexSkillMetadata + readonly 'allowed-tools'?: string +} + +/** + * Kiro steering file front matter + * @see https://kiro.dev/docs/steering + */ +export interface KiroSteeringYAMLFrontMatter extends YAMLFrontMatter { + readonly inclusion?: 'always' | 'fileMatch' | 'manual' + readonly fileMatchPattern?: string +} + +/** + * Kiro Power POWER.md front matter + * @see https://kiro.dev/docs/powers + */ +export interface KiroPowerYAMLFrontMatter extends SkillsYAMLFrontMatter { + readonly displayName?: string + readonly keywords?: readonly string[] + readonly author?: string +} + +/** + * Rule YAML front matter with glob patterns and scope + */ +export interface RuleYAMLFrontMatter extends CommonYAMLFrontMatter { + readonly globs: readonly string[] + readonly scope?: RuleScope + readonly seriName?: string | string[] | null +} + +/** + * Global memory prompt + * Single output target + */ +export interface GlobalMemoryPrompt extends Prompt< + PromptKind.GlobalMemory +> { + readonly type: PromptKind.GlobalMemory + readonly parentDirectoryPath: GlobalConfigDirectory +} diff --git a/cli/src/plugins/plugin-shared/types/RegistryTypes.ts b/cli/src/plugins/plugin-shared/types/RegistryTypes.ts new file mode 100644 index 00000000..0054f51a --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/RegistryTypes.ts @@ -0,0 +1,106 @@ +/** + * Registry Configuration Writer Types + * + * Type definitions for registry data structures used by output plugins + * to register their outputs in external tool registry files. + * + * @see Requirements 2.1, 2.2, 2.3, 3.1, 3.2, 3.3, 3.5 + */ + +/** + * Generic registry data structure. + * All registry files must have version and lastUpdated fields. + * + * @see Requirements 1.8 + */ +export interface RegistryData { + readonly version: string + readonly lastUpdated: string +} + +/** + * Result of a registry operation. + * + * @see Requirements 5.4 + */ +export interface RegistryOperationResult { + readonly success: boolean + readonly entryName: string + readonly error?: Error +} + +/** + * Source information for a Kiro power. + * Indicates the origin type of a registered power. + * + * @see Requirements 3.1, 3.2 + */ +export interface KiroPowerSource { + readonly type: 'local' | 'repo' | 'registry' + readonly repoId?: string + readonly repoName?: string + readonly cloneId?: string +} + +/** + * A single power entry in the Kiro registry. + * Contains metadata about an installed power. + * + * Field order matches Kiro's expected format: + * name → description → mcpServers → author → keywords → displayName → installed → installedAt → installPath → source → sourcePath + * + * @see Requirements 2.1, 2.2, 2.3, 2.4 + */ +export interface KiroPowerEntry { + readonly name: string + readonly description: string + readonly mcpServers?: readonly string[] + readonly author?: string + readonly keywords: readonly string[] + readonly displayName?: string + readonly installed: boolean + readonly installedAt?: string + readonly installPath?: string + readonly source: KiroPowerSource + readonly sourcePath?: string +} + +/** + * Repository source tracking in Kiro registry. + * Tracks the source/origin of registered items. + * + * @see Requirements 3.1, 3.2, 3.3, 3.5 + */ +export interface KiroRepoSource { + readonly name: string + readonly type: 'local' | 'git' + readonly enabled: boolean + readonly addedAt?: string + readonly powerCount: number + readonly path?: string + readonly lastSync?: string + readonly powers?: readonly string[] +} + +/** + * Kiro recommended repo metadata (preserved during updates). + * + * @see Requirements 4.5, 4.6 + */ +export interface KiroRecommendedRepo { + readonly url: string + readonly lastFetch: string + readonly powerCount: number +} + +/** + * Complete Kiro powers registry structure. + * Represents the full ~/.kiro/powers/registry.json file. + * + * @see Requirements 4.1, 4.2 + */ +export interface KiroPowersRegistry extends RegistryData { + readonly powers: Record + readonly repoSources: Record + readonly kiroRecommendedRepo?: KiroRecommendedRepo +} diff --git a/cli/src/plugins/plugin-shared/types/ShadowSourceProjectTypes.ts b/cli/src/plugins/plugin-shared/types/ShadowSourceProjectTypes.ts new file mode 100644 index 00000000..61a8788e --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/ShadowSourceProjectTypes.ts @@ -0,0 +1,298 @@ +/** + * Shadow Source Project (aindex) directory structure types and constants + * Used for directory structure validation and generation + */ + +/** + * File entry in the shadow source project + */ +export interface ShadowSourceFileEntry { + /** File name (e.g., 'GLOBAL.md') */ + readonly name: string + /** Whether this file is required */ + readonly required: boolean + /** File description */ + readonly description?: string +} + +/** + * Directory entry in the shadow source project + */ +export interface ShadowSourceDirectoryEntry { + /** Directory name (e.g., 'skills') */ + readonly name: string + /** Whether this directory is required */ + readonly required: boolean + /** Directory description */ + readonly description?: string + /** Nested directories */ + readonly directories?: readonly ShadowSourceDirectoryEntry[] + /** Files in this directory */ + readonly files?: readonly ShadowSourceFileEntry[] +} + +/** + * Root structure of the shadow source project + */ +export interface ShadowSourceProjectDirectory { + /** Source directories (before compilation) */ + readonly src: { + readonly skills: ShadowSourceDirectoryEntry + readonly commands: ShadowSourceDirectoryEntry + readonly agents: ShadowSourceDirectoryEntry + readonly rules: ShadowSourceDirectoryEntry + readonly globalMemoryFile: ShadowSourceFileEntry + readonly workspaceMemoryFile: ShadowSourceFileEntry + } + /** Distribution directories (after compilation) */ + readonly dist: { + readonly skills: ShadowSourceDirectoryEntry + readonly commands: ShadowSourceDirectoryEntry + readonly agents: ShadowSourceDirectoryEntry + readonly rules: ShadowSourceDirectoryEntry + readonly app: ShadowSourceDirectoryEntry + readonly globalMemoryFile: ShadowSourceFileEntry + readonly workspaceMemoryFile: ShadowSourceFileEntry + } + /** App directory (project-specific prompts source, standalone at root) */ + readonly app: ShadowSourceDirectoryEntry + /** IDE configuration directories */ + readonly ide: { + readonly idea: ShadowSourceDirectoryEntry + readonly ideaCodeStyles: ShadowSourceDirectoryEntry + readonly vscode: ShadowSourceDirectoryEntry + } + /** IDE configuration files */ + readonly ideFiles: readonly ShadowSourceFileEntry[] + /** AI Agent ignore files */ + readonly ignoreFiles: readonly ShadowSourceFileEntry[] +} + +/** + * Directory names used in shadow source project + */ +export const SHADOW_SOURCE_DIR_NAMES = { + SRC: 'src', + DIST: 'dist', + SKILLS: 'skills', + COMMANDS: 'commands', + AGENTS: 'agents', + RULES: 'rules', + APP: 'app', + IDEA: '.idea', // IDE directories + IDEA_CODE_STYLES: '.idea/codeStyles', + VSCODE: '.vscode' +} as const + +/** + * File names used in shadow source project + */ +export const SHADOW_SOURCE_FILE_NAMES = { + GLOBAL_MEMORY: 'global.mdx', // Global memory + GLOBAL_MEMORY_SRC: 'global.cn.mdx', + WORKSPACE_MEMORY: 'workspace.mdx', // Workspace memory + WORKSPACE_MEMORY_SRC: 'workspace.cn.mdx', + EDITOR_CONFIG: '.editorconfig', // EditorConfig + IDEA_GITIGNORE: '.idea/.gitignore', // JetBrains IDE + IDEA_PROJECT_XML: '.idea/codeStyles/Project.xml', + IDEA_CODE_STYLE_CONFIG_XML: '.idea/codeStyles/codeStyleConfig.xml', + VSCODE_SETTINGS: '.vscode/settings.json', // VS Code + VSCODE_EXTENSIONS: '.vscode/extensions.json', + QODER_IGNORE: '.qoderignore', // AI Agent ignore files + CURSOR_IGNORE: '.cursorignore', + WARP_INDEX_IGNORE: '.warpindexignore', + AI_IGNORE: '.aiignore', + CODEIUM_IGNORE: '.codeiumignore' // Windsurf ignore file +} as const + +/** + * Relative paths from shadow source project root + */ +export const SHADOW_SOURCE_RELATIVE_PATHS = { + SRC_SKILLS: 'src/skills', // Source paths + SRC_COMMANDS: 'src/commands', + SRC_AGENTS: 'src/agents', + SRC_RULES: 'src/rules', + SRC_GLOBAL_MEMORY: 'app/global.cn.mdx', + SRC_WORKSPACE_MEMORY: 'app/workspace.cn.mdx', + DIST_SKILLS: 'dist/skills', // Distribution paths + DIST_COMMANDS: 'dist/commands', + DIST_AGENTS: 'dist/agents', + DIST_RULES: 'dist/rules', + DIST_APP: 'dist/app', + DIST_GLOBAL_MEMORY: 'dist/global.mdx', + DIST_WORKSPACE_MEMORY: 'dist/app/workspace.mdx', + APP: 'app' // App source path (standalone at root) +} as const + +/** + * Default shadow source project directory structure + * Used for validation and generation + */ +export const DEFAULT_SHADOW_SOURCE_PROJECT_STRUCTURE: ShadowSourceProjectDirectory = { + src: { + skills: { + name: SHADOW_SOURCE_DIR_NAMES.SKILLS, + required: false, + description: 'Skill source files (.cn.mdx)' + }, + commands: { + name: SHADOW_SOURCE_DIR_NAMES.COMMANDS, + required: false, + description: 'Fast command source files (.cn.mdx)' + }, + agents: { + name: SHADOW_SOURCE_DIR_NAMES.AGENTS, + required: false, + description: 'Sub-agent source files (.cn.mdx)' + }, + rules: { + name: SHADOW_SOURCE_DIR_NAMES.RULES, + required: false, + description: 'Rule source files (.cn.mdx)' + }, + globalMemoryFile: { + name: SHADOW_SOURCE_FILE_NAMES.GLOBAL_MEMORY_SRC, + required: false, + description: 'Global memory source file' + }, + workspaceMemoryFile: { + name: SHADOW_SOURCE_FILE_NAMES.WORKSPACE_MEMORY_SRC, + required: false, + description: 'Workspace memory source file' + } + }, + dist: { + skills: { + name: SHADOW_SOURCE_DIR_NAMES.SKILLS, + required: false, + description: 'Compiled skill files (.mdx)' + }, + commands: { + name: SHADOW_SOURCE_DIR_NAMES.COMMANDS, + required: false, + description: 'Compiled fast command files (.mdx)' + }, + agents: { + name: SHADOW_SOURCE_DIR_NAMES.AGENTS, + required: false, + description: 'Compiled sub-agent files (.mdx)' + }, + rules: { + name: SHADOW_SOURCE_DIR_NAMES.RULES, + required: false, + description: 'Compiled rule files (.mdx)' + }, + globalMemoryFile: { + name: SHADOW_SOURCE_FILE_NAMES.GLOBAL_MEMORY, + required: false, + description: 'Compiled global memory file' + }, + workspaceMemoryFile: { + name: SHADOW_SOURCE_FILE_NAMES.WORKSPACE_MEMORY, + required: false, + description: 'Compiled workspace memory file' + }, + app: { + name: SHADOW_SOURCE_DIR_NAMES.APP, + required: false, + description: 'Compiled project-specific prompts' + } + }, + app: { + name: SHADOW_SOURCE_DIR_NAMES.APP, + required: false, + description: 'Project-specific prompts (standalone directory)' + }, + ide: { + idea: { + name: SHADOW_SOURCE_DIR_NAMES.IDEA, + required: false, + description: 'JetBrains IDE configuration directory' + }, + ideaCodeStyles: { + name: SHADOW_SOURCE_DIR_NAMES.IDEA_CODE_STYLES, + required: false, + description: 'JetBrains IDE code styles directory' + }, + vscode: { + name: SHADOW_SOURCE_DIR_NAMES.VSCODE, + required: false, + description: 'VS Code configuration directory' + } + }, + ideFiles: [ + { + name: SHADOW_SOURCE_FILE_NAMES.EDITOR_CONFIG, + required: false, + description: 'EditorConfig file' + }, + { + name: SHADOW_SOURCE_FILE_NAMES.IDEA_GITIGNORE, + required: false, + description: 'JetBrains IDE .gitignore' + }, + { + name: SHADOW_SOURCE_FILE_NAMES.IDEA_PROJECT_XML, + required: false, + description: 'JetBrains IDE Project.xml' + }, + { + name: SHADOW_SOURCE_FILE_NAMES.IDEA_CODE_STYLE_CONFIG_XML, + required: false, + description: 'JetBrains IDE codeStyleConfig.xml' + }, + { + name: SHADOW_SOURCE_FILE_NAMES.VSCODE_SETTINGS, + required: false, + description: 'VS Code settings.json' + }, + { + name: SHADOW_SOURCE_FILE_NAMES.VSCODE_EXTENSIONS, + required: false, + description: 'VS Code extensions.json' + } + ], + ignoreFiles: [ + { + name: SHADOW_SOURCE_FILE_NAMES.QODER_IGNORE, + required: false, + description: 'Qoder ignore file' + }, + { + name: SHADOW_SOURCE_FILE_NAMES.CURSOR_IGNORE, + required: false, + description: 'Cursor ignore file' + }, + { + name: SHADOW_SOURCE_FILE_NAMES.WARP_INDEX_IGNORE, + required: false, + description: 'Warp index ignore file' + }, + { + name: SHADOW_SOURCE_FILE_NAMES.AI_IGNORE, + required: false, + description: 'AI ignore file' + }, + { + name: SHADOW_SOURCE_FILE_NAMES.CODEIUM_IGNORE, + required: false, + description: 'Windsurf ignore file' + } + ] +} as const + +/** + * Type for directory names + */ +export type ShadowSourceDirName = (typeof SHADOW_SOURCE_DIR_NAMES)[keyof typeof SHADOW_SOURCE_DIR_NAMES] + +/** + * Type for file names + */ +export type ShadowSourceFileName = (typeof SHADOW_SOURCE_FILE_NAMES)[keyof typeof SHADOW_SOURCE_FILE_NAMES] + +/** + * Type for relative paths + */ +export type ShadowSourceRelativePath = (typeof SHADOW_SOURCE_RELATIVE_PATHS)[keyof typeof SHADOW_SOURCE_RELATIVE_PATHS] diff --git a/cli/src/plugins/plugin-shared/types/index.ts b/cli/src/plugins/plugin-shared/types/index.ts new file mode 100644 index 00000000..7d516e26 --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/index.ts @@ -0,0 +1,11 @@ +export * from './ConfigTypes.schema' +export * from './Enums' +export * from './Errors' +export * from './ExportMetadataTypes' +export * from './FileSystemTypes' +export * from './InputTypes' +export * from './OutputTypes' +export * from './PluginTypes' +export * from './PromptTypes' +export * from './RegistryTypes' +export * from './ShadowSourceProjectTypes' diff --git a/cli/src/plugins/plugin-shared/types/seriNamePropagation.property.test.ts b/cli/src/plugins/plugin-shared/types/seriNamePropagation.property.test.ts new file mode 100644 index 00000000..340e7c90 --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/seriNamePropagation.property.test.ts @@ -0,0 +1,82 @@ +/** Property 8: seriName front matter propagation. Validates: Requirement 2.5 */ +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' + +const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) + .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) + +const seriNameArb: fc.Arbitrary = fc.oneof( + fc.constant(null), + fc.constant(void 0), + seriesNameArb, + fc.array(seriesNameArb, {minLength: 1, maxLength: 5}) +) + +function propagateSeriName( + frontMatter: {readonly seriName?: string | string[] | null} | undefined +): {readonly seriName?: string | string[] | null} { + const seriName = frontMatter?.seriName + return { + ...seriName != null && {seriName} + } +} + +describe('property 8: seriName front matter propagation', () => { + it('propagated seriName matches front matter value for non-null/undefined values', () => { // **Validates: Requirement 2.5** + fc.assert( + fc.property( + seriNameArb, + seriName => { + const frontMatter = seriName === void 0 ? {} : {seriName} + const result = propagateSeriName(frontMatter) + + if (seriName == null) { + expect(result.seriName).toBeUndefined() // null and undefined should not appear on the prompt object + } else { + expect(result.seriName).toEqual(seriName) // string and string[] should be propagated exactly + } + } + ), + {numRuns: 200} + ) + }) + + it('undefined front matter produces no seriName on prompt', () => { // **Validates: Requirement 2.5** + fc.assert( + fc.property( + fc.constant(void 0), + frontMatter => { + const result = propagateSeriName(frontMatter) + expect(result.seriName).toBeUndefined() + } + ), + {numRuns: 10} + ) + }) + + it('string seriName is always propagated identically', () => { // **Validates: Requirement 2.5** + fc.assert( + fc.property( + seriesNameArb, + seriName => { + const result = propagateSeriName({seriName}) + expect(result.seriName).toBe(seriName) + } + ), + {numRuns: 200} + ) + }) + + it('array seriName is always propagated identically', () => { // **Validates: Requirement 2.5** + fc.assert( + fc.property( + fc.array(seriesNameArb, {minLength: 1, maxLength: 5}), + seriName => { + const result = propagateSeriName({seriName}) + expect(result.seriName).toEqual(seriName) + } + ), + {numRuns: 200} + ) + }) +}) diff --git a/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.test.ts b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.test.ts new file mode 100644 index 00000000..07a71e45 --- /dev/null +++ b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.test.ts @@ -0,0 +1,135 @@ +import type {FastCommandPrompt, OutputWriteContext, Project, ProjectChildrenMemoryPrompt, RelativePath, WriteResult} from '@truenine/plugin-shared' +import {FilePathKind, PromptKind} from '@truenine/plugin-shared' +import * as fc from 'fast-check' +import {describe, expect, it} from 'vitest' +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/plugin-trae-ide/TraeIDEOutputPlugin.ts b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts new file mode 100644 index 00000000..971eb8c3 --- /dev/null +++ b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts @@ -0,0 +1,167 @@ +import type { + FastCommandPrompt, + OutputPluginContext, + OutputWriteContext, + Project, + ProjectChildrenMemoryPrompt, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import * as path from 'node:path' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {filterCommandsByProjectConfig} from '@truenine/plugin-output-shared/utils' + +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'}) + } + + protected override getIgnoreOutputPath(): string | undefined { + if (this.indexignore == null) return void 0 + return path.join('.trae', '.ignore') + } + + 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 projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const steeringDir = this.getGlobalSteeringDir() + const results: RelativePath[] = [] + + if (globalMemory != null) results.push(this.createRelativePath(GLOBAL_MEMORY_FILE, steeringDir, () => STEERING_SUBDIR)) + + if (fastCommands == null) return results + + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + for (const cmd of filteredCommands) 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 projectConfig = this.resolvePromptSourceProjectConfig(ctx) + 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) return {files: fileResults, dirs: []} + + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + for (const cmd of filteredCommands) 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/plugin-trae-ide/index.ts b/cli/src/plugins/plugin-trae-ide/index.ts new file mode 100644 index 00000000..d194f82b --- /dev/null +++ b/cli/src/plugins/plugin-trae-ide/index.ts @@ -0,0 +1,3 @@ +export { + TraeIDEOutputPlugin +} from './TraeIDEOutputPlugin' diff --git a/cli/src/plugins/plugin-vscode/VisualStudioCodeIDEConfigOutputPlugin.ts b/cli/src/plugins/plugin-vscode/VisualStudioCodeIDEConfigOutputPlugin.ts new file mode 100644 index 00000000..f58a4eaf --- /dev/null +++ b/cli/src/plugins/plugin-vscode/VisualStudioCodeIDEConfigOutputPlugin.ts @@ -0,0 +1,134 @@ +import type { + OutputPluginContext, + OutputWriteContext, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {FilePathKind, IDEKind} from '@truenine/plugin-shared' + +const VSCODE_DIR = '.vscode' + +/** + * Default VS Code config files that this plugin manages. + * These are the relative paths within each project directory. + */ +const VSCODE_CONFIG_FILES = [ + '.vscode/settings.json', + '.vscode/extensions.json' +] as const + +export class VisualStudioCodeIDEConfigOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('VisualStudioCodeIDEConfigOutputPlugin') + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + const {vscodeConfigFiles} = ctx.collectedInputContext + + const hasVSCodeConfigs = vscodeConfigFiles != null && vscodeConfigFiles.length > 0 + if (!hasVSCodeConfigs) return results + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + + if (project.isPromptSourceProject === true) continue + + for (const configFile of VSCODE_CONFIG_FILES) { + const filePath = this.joinPath(projectDir.path, configFile) + results.push({ + pathKind: FilePathKind.Relative, + path: filePath, + basePath: projectDir.basePath, + getDirectoryName: () => this.dirname(configFile), + getAbsolutePath: () => this.resolvePath(projectDir.basePath, filePath) + }) + } + } + + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {vscodeConfigFiles} = ctx.collectedInputContext + const hasVSCodeConfigs = vscodeConfigFiles != null && vscodeConfigFiles.length > 0 + + if (hasVSCodeConfigs) return true + + this.log.debug('skipped', {reason: 'no VS Code config files found'}) + return false + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const {projects} = ctx.collectedInputContext.workspace + const {vscodeConfigFiles} = ctx.collectedInputContext + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + const vscodeConfigs = vscodeConfigFiles ?? [] + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + + const projectName = project.name ?? 'unknown' + + for (const config of vscodeConfigs) { + const result = await this.writeConfigFile(ctx, projectDir, config, `project:${projectName}`) + fileResults.push(result) + } + } + + return {files: fileResults, dirs: dirResults} + } + + private async writeConfigFile( + ctx: OutputWriteContext, + projectDir: RelativePath, + config: {type: IDEKind, content: string, dir: {path: string}}, + label: string + ): Promise { + const targetRelativePath = this.getTargetRelativePath(config) + const fullPath = this.resolvePath(projectDir.basePath, projectDir.path, targetRelativePath) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: this.joinPath(projectDir.path, targetRelativePath), + basePath: projectDir.basePath, + getDirectoryName: () => this.dirname(targetRelativePath), + getAbsolutePath: () => fullPath + } + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'config', path: fullPath, label}) + return {path: relativePath, success: true, skipped: false} + } + + try { + const dir = this.dirname(fullPath) + this.ensureDirectory(dir) + this.writeFileSync(fullPath, config.content) + this.log.trace({action: 'write', type: 'config', 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: 'config', path: fullPath, label, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private getTargetRelativePath(config: {type: IDEKind, dir: {path: string}}): string { + const sourcePath = config.dir.path + + if (config.type !== IDEKind.VSCode) return this.basename(sourcePath) + + const vscodeIndex = sourcePath.indexOf(VSCODE_DIR) + if (vscodeIndex !== -1) return sourcePath.slice(Math.max(0, vscodeIndex)) + return this.joinPath(VSCODE_DIR, this.basename(sourcePath)) + } +} diff --git a/cli/src/plugins/plugin-vscode/index.ts b/cli/src/plugins/plugin-vscode/index.ts new file mode 100644 index 00000000..c8848542 --- /dev/null +++ b/cli/src/plugins/plugin-vscode/index.ts @@ -0,0 +1,3 @@ +export { + VisualStudioCodeIDEConfigOutputPlugin +} from './VisualStudioCodeIDEConfigOutputPlugin' diff --git a/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.test.ts b/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.test.ts new file mode 100644 index 00000000..c61ea29c --- /dev/null +++ b/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.test.ts @@ -0,0 +1,513 @@ +import type { + CollectedInputContext, + GlobalMemoryPrompt, + OutputPluginContext, + OutputWriteContext, + ProjectRootMemoryPrompt, + RelativePath +} from '@truenine/plugin-shared' +import fs from 'node:fs' +import path from 'node:path' +import {FilePathKind, PromptKind} from '@truenine/plugin-shared' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {WarpIDEOutputPlugin} from './WarpIDEOutputPlugin' + +vi.mock('node:fs') // Mock fs module + +describe('warpIDEOutputPlugin', () => { + const mockWorkspaceDir = '/workspace/test' + let plugin: WarpIDEOutputPlugin + + beforeEach(() => { + plugin = new WarpIDEOutputPlugin() + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.mkdirSync).mockReturnValue(void 0) + vi.mocked(fs.writeFileSync).mockReturnValue(void 0) + }) + + 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 createMockRootMemoryPrompt(content: string): ProjectRootMemoryPrompt { + return { + type: PromptKind.ProjectRootMemory, + content, + dir: createMockRelativePath('.', mockWorkspaceDir), + markdownContents: [], + length: content.length, + filePathKind: FilePathKind.Relative + } as ProjectRootMemoryPrompt + } + + function createMockGlobalMemoryPrompt(content: string): GlobalMemoryPrompt { + return { + type: PromptKind.GlobalMemory, + content, + dir: createMockRelativePath('.', mockWorkspaceDir), + markdownContents: [], + length: content.length, + filePathKind: FilePathKind.Relative, + parentDirectoryPath: { + type: 'UserHome', + directory: createMockRelativePath('.memory', '/home/user') + } + } as GlobalMemoryPrompt + } + + function createMockOutputWriteContext( + collectedInputContext: Partial, + dryRun = false, + registeredPluginNames: readonly string[] = [] + ): OutputWriteContext { + return { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [] + }, + ideConfigFiles: [], + ...collectedInputContext + } as CollectedInputContext, + dryRun, + registeredPluginNames + } + } + + describe('registerProjectOutputFiles', () => { + it('should register WARP.md for project with rootMemoryPrompt', async () => { + const projectDir = createMockRelativePath('project1', mockWorkspaceDir) + const ctx: OutputPluginContext = { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: createMockRootMemoryPrompt('test content') + } + ] + }, + ideConfigFiles: [] + } as CollectedInputContext + } + + const results = await plugin.registerProjectOutputFiles(ctx) + + 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 () => { + const projectDir = createMockRelativePath('project1', mockWorkspaceDir) + const childDir = createMockRelativePath('project1/src', mockWorkspaceDir) + + const ctx: OutputPluginContext = { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: projectDir, + childMemoryPrompts: [ + { + type: PromptKind.ProjectChildrenMemory, + dir: childDir, + content: 'child content', + workingChildDirectoryPath: childDir, + markdownContents: [], + length: 13, + filePathKind: FilePathKind.Relative + } + ] + } + ] + }, + ideConfigFiles: [] + } as CollectedInputContext + } + + const results = await plugin.registerProjectOutputFiles(ctx) + + 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 () => { + const ctx: OutputPluginContext = { + collectedInputContext: { + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project' + } + ] + }, + ideConfigFiles: [] + } as CollectedInputContext + } + + const results = await plugin.registerProjectOutputFiles(ctx) + + expect(results).toHaveLength(0) + }) + }) + + describe('canWrite', () => { + it('should return false when AgentsOutputPlugin is registered', async () => { + const ctx = createMockOutputWriteContext( + { + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), + rootMemoryPrompt: createMockRootMemoryPrompt('test content') + } + ] as any + } as any + }, + false, + ['AgentsOutputPlugin'] + ) + + const result = await plugin.canWrite(ctx) + + expect(result).toBe(false) + }) + + it('should return true when project has rootMemoryPrompt and AgentsOutputPlugin is not registered', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), + rootMemoryPrompt: createMockRootMemoryPrompt('test content') + } + ] as any + } as any + }) + + const result = await plugin.canWrite(ctx) + + expect(result).toBe(true) + }) + + it('should return true when project has childMemoryPrompts', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), + childMemoryPrompts: [ + { + type: PromptKind.ProjectChildrenMemory, + dir: createMockRelativePath('project1/src', mockWorkspaceDir), + content: 'child content', + workingChildDirectoryPath: createMockRelativePath('src', mockWorkspaceDir), + markdownContents: [], + length: 13, + filePathKind: FilePathKind.Relative + } + ] + } + ] as any + } as any + }) + + const result = await plugin.canWrite(ctx) + + expect(result).toBe(true) + }) + + it('should return false when no outputs exist', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project' + } + ] as any + } as any + }) + + const result = await plugin.canWrite(ctx) + + expect(result).toBe(false) + }) + }) + + describe('writeProjectOutputs', () => { + it('should write rootMemoryPrompt without globalMemory', async () => { + const projectDir = createMockRelativePath('project1', mockWorkspaceDir) + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: createMockRootMemoryPrompt('# Project Rules\n\nThis is project content.') + } + ] as any + } as any + }) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files).toHaveLength(1) + expect(results.files[0].success).toBe(true) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( + expect.stringContaining('WARP.md'), + '# Project Rules\n\nThis is project content.', + 'utf8' + ) + }) + + it('should combine globalMemory with rootMemoryPrompt', async () => { + const projectDir = createMockRelativePath('project1', mockWorkspaceDir) + const globalMemory = createMockGlobalMemoryPrompt('# Global Rules\n\nThese are global rules.') + const rootMemory = createMockRootMemoryPrompt('# Project Rules\n\nThese are project rules.') + + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: rootMemory + } + ] as any + } as any, + globalMemory + }) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files).toHaveLength(1) + expect(results.files[0].success).toBe(true) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( + expect.stringContaining('WARP.md'), + '# Global Rules\n\nThese are global rules.\n\n# Project Rules\n\nThese are project rules.', + 'utf8' + ) + }) + + it('should prepend globalMemory to the beginning of rootMemoryPrompt', async () => { + const projectDir = createMockRelativePath('project1', mockWorkspaceDir) + const globalContent = 'Global content first' + const projectContent = 'Project content second' + const globalMemory = createMockGlobalMemoryPrompt(globalContent) + const rootMemory = createMockRootMemoryPrompt(projectContent) + + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: rootMemory + } + ] as any + } as any, + globalMemory + }) + + await plugin.writeProjectOutputs(ctx) + + const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] + const writtenContent = writeCall[1] as string + + const globalIndex = writtenContent.indexOf(globalContent) // Verify global content comes first + const projectIndex = writtenContent.indexOf(projectContent) + + expect(globalIndex).toBeGreaterThanOrEqual(0) + expect(projectIndex).toBeGreaterThan(globalIndex) + expect(writtenContent).toBe(`${globalContent}\n\n${projectContent}`) + }) + + it('should skip globalMemory if it is empty or whitespace', async () => { + const projectDir = createMockRelativePath('project1', mockWorkspaceDir) + const globalMemory = createMockGlobalMemoryPrompt(' \n\n ') + const rootMemory = createMockRootMemoryPrompt('# Project Rules') + + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: rootMemory + } + ] as any + } as any, + globalMemory + }) + + await plugin.writeProjectOutputs(ctx) + + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( + expect.stringContaining('WARP.md'), + '# Project Rules', + 'utf8' + ) + }) + + it('should write multiple projects with globalMemory', async () => { + const globalMemory = createMockGlobalMemoryPrompt('Global rules') + + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'project-1', + dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), + rootMemoryPrompt: createMockRootMemoryPrompt('Project 1 rules') + }, + { + name: 'project-2', + dirFromWorkspacePath: createMockRelativePath('project2', mockWorkspaceDir), + rootMemoryPrompt: createMockRootMemoryPrompt('Project 2 rules') + } + ] as any + } as any, + globalMemory + }) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files).toHaveLength(2) + expect(results.files[0].success).toBe(true) + expect(results.files[1].success).toBe(true) + + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(2) // Verify both files have global memory prepended + expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('project1'), + 'Global rules\n\nProject 1 rules', + 'utf8' + ) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('project2'), + 'Global rules\n\nProject 2 rules', + 'utf8' + ) + }) + + it('should not add globalMemory to child prompts', async () => { + const globalMemory = createMockGlobalMemoryPrompt('Global rules') + const projectDir = createMockRelativePath('project1', mockWorkspaceDir) + const childDir = createMockRelativePath('project1/src', mockWorkspaceDir) + + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: createMockRootMemoryPrompt('Root rules'), + childMemoryPrompts: [ + { + type: PromptKind.ProjectChildrenMemory, + dir: childDir, + content: 'Child rules', + workingChildDirectoryPath: childDir, + markdownContents: [], + length: 11, + filePathKind: FilePathKind.Relative + } + ] + } + ] as any + } as any, + globalMemory + }) + + await plugin.writeProjectOutputs(ctx) + + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(2) + + expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( // Root prompt should have global memory + 1, + expect.stringContaining(path.join('project1', 'WARP.md')), + 'Global rules\n\nRoot rules', + 'utf8' + ) + + expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( // Child prompt should NOT have global memory + 2, + expect.stringContaining(path.join('project1', 'src', 'WARP.md')), + 'Child rules', + 'utf8' + ) + }) + + it('should skip project without dirFromWorkspacePath', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + rootMemoryPrompt: createMockRootMemoryPrompt('content') + } + ] as any + } as any + }) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files).toHaveLength(0) + expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() + }) + + it('should support dry-run mode', async () => { + const projectDir = createMockRelativePath('project1', mockWorkspaceDir) + const ctx = createMockOutputWriteContext( + { + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [ + { + name: 'test-project', + dirFromWorkspacePath: projectDir, + rootMemoryPrompt: createMockRootMemoryPrompt('test content') + } + ] as any + } as any + }, + true + ) + + const results = await plugin.writeProjectOutputs(ctx) + + expect(results.files).toHaveLength(1) + expect(results.files[0].success).toBe(true) + expect(results.files[0].skipped).toBe(false) + expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() + }) + }) +}) diff --git a/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.ts b/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.ts new file mode 100644 index 00000000..18f0a8f4 --- /dev/null +++ b/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.ts @@ -0,0 +1,128 @@ +import type { + OutputPluginContext, + OutputWriteContext, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {PLUGIN_NAMES} from '@truenine/plugin-shared' + +const PROJECT_MEMORY_FILE = 'WARP.md' + +export class WarpIDEOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('WarpIDEOutputPlugin', {outputFileName: PROJECT_MEMORY_FILE, indexignore: '.warpindexignore'}) + } + + private isAgentsPluginRegisteredInCtx(ctx: OutputPluginContext | OutputWriteContext): boolean { + if ('registeredPluginNames' in ctx && ctx.registeredPluginNames != null) return ctx.registeredPluginNames.includes(PLUGIN_NAMES.AgentsOutput) + return false + } + + async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + const agentsRegistered = this.isAgentsPluginRegisteredInCtx(ctx) + + for (const project of projects) { + if (project.dirFromWorkspacePath == null) continue + + if (agentsRegistered) { + results.push(this.createFileRelativePath(project.dirFromWorkspacePath, PROJECT_MEMORY_FILE)) // When AgentsOutputPlugin is registered, register WARP.md for global prompt output to each project + } else { + if (project.rootMemoryPrompt != null) results.push(this.createFileRelativePath(project.dirFromWorkspacePath, PROJECT_MEMORY_FILE)) // Normal mode: register files for projects with prompts + + if (project.childMemoryPrompts != null) { + for (const child of project.childMemoryPrompts) { + if (child.dir != null && this.isRelativePath(child.dir)) results.push(this.createFileRelativePath(child.dir, PROJECT_MEMORY_FILE)) + } + } + } + } + + results.push(...this.registerProjectIgnoreOutputFiles(projects)) + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const agentsRegistered = this.shouldSkipDueToPlugin(ctx, PLUGIN_NAMES.AgentsOutput) + const {workspace, globalMemory, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + + if (agentsRegistered) { + if (globalMemory == null) { // When AgentsOutputPlugin is registered, only write if we have global memory + this.log.debug('skipped', {reason: 'AgentsOutputPlugin registered but no global memory'}) + return false + } + return true + } + + const hasProjectOutputs = workspace.projects.some( // Normal mode: check for project outputs + p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 + ) + + 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 + } + + async writeProjectOutputs(ctx: OutputWriteContext): Promise { + const agentsRegistered = this.shouldSkipDueToPlugin(ctx, PLUGIN_NAMES.AgentsOutput) + const {workspace, globalMemory} = ctx.collectedInputContext + const {projects} = workspace + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + if (agentsRegistered) { + 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) // Normal mode: write combined content + + for (const project of projects) { + const projectName = project.name ?? 'unknown' + const projectDir = project.dirFromWorkspacePath + + if (projectDir == null) continue + + if (project.rootMemoryPrompt != null) { // Write root memory prompt (only if exists) + const combinedContent = this.combineGlobalWithContent( + globalMemoryContent, + project.rootMemoryPrompt.content as string + ) + + const result = await this.writePromptFile(ctx, projectDir, combinedContent, `project:${projectName}/root`) + fileResults.push(result) + } + + if (project.childMemoryPrompts != null) { // Write children memory prompts + for (const child of project.childMemoryPrompts) { + const childResult = await this.writePromptFile(ctx, child.dir, child.content as string, `project:${projectName}/child:${child.workingChildDirectoryPath?.path ?? 'unknown'}`) + fileResults.push(childResult) + } + } + } + + const ignoreResults = await this.writeProjectIgnoreFiles(ctx) + fileResults.push(...ignoreResults) + + return {files: fileResults, dirs: dirResults} + } +} diff --git a/cli/src/plugins/plugin-warp-ide/index.ts b/cli/src/plugins/plugin-warp-ide/index.ts new file mode 100644 index 00000000..b9e1bf10 --- /dev/null +++ b/cli/src/plugins/plugin-warp-ide/index.ts @@ -0,0 +1,3 @@ +export { + WarpIDEOutputPlugin +} from './WarpIDEOutputPlugin' diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts new file mode 100644 index 00000000..9344187b --- /dev/null +++ b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts @@ -0,0 +1,213 @@ +import type {OutputPluginContext} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' + +class TestableWindsurfOutputPlugin extends WindsurfOutputPlugin { + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } +} + +function createMockContext( + tempDir: string, + rules: unknown[], + projects: unknown[] +): OutputPluginContext { + return { + collectedInputContext: { + workspace: { + projects: projects as never, + directory: { + pathKind: 1, + path: tempDir, + basePath: tempDir, + getDirectoryName: () => 'workspace', + getAbsolutePath: () => tempDir + } + }, + ideConfigFiles: [], + rules: rules as never, + fastCommands: [], + skills: [], + globalMemory: void 0, + aiAgentIgnoreConfigFiles: [] + }, + logger: { + debug: vi.fn(), + trace: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + } as never, + fs, + path, + glob: vi.fn() as never + } +} + +describe('windsurfOutputPlugin - projectConfig filtering', () => { + let tempDir: string, + plugin: TestableWindsurfOutputPlugin + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-proj-config-test-')) + plugin = new TestableWindsurfOutputPlugin() + plugin.setMockHomeDir(tempDir) + }) + + afterEach(() => { + try { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch {} + }) + + describe('registerProjectOutputFiles', () => { + it('should include all project rules when no projectConfig', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [createMockProject('proj1', tempDir, 'proj1')] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.md') + expect(fileNames).toContain('rule-test-rule2.md') + }) + + it('should filter rules by include in projectConfig', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.md') + expect(fileNames).not.toContain('rule-test-rule2.md') + }) + + it('should filter rules by includeSeries excluding non-matching series', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).not.toContain('rule-test-rule1.md') + expect(fileNames).toContain('rule-test-rule2.md') + }) + + it('should include rules without seriName regardless of include filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', void 0, 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = collectFileNames(results) + + expect(fileNames).toContain('rule-test-rule1.md') + expect(fileNames).not.toContain('rule-test-rule2.md') + }) + + it('should filter independently for each project', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), + createMockRulePrompt('test', 'rule2', 'vue', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}), + createMockProject('proj2', tempDir, 'proj2', {rules: {includeSeries: ['vue']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const fileNames = results.map(r => ({ + path: r.path, + fileName: r.path.split(/[/\\]/).pop() + })) + + expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule1.md')).toBe(true) + expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule2.md')).toBe(false) + expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule2.md')).toBe(true) + expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule1.md')).toBe(false) + }) + + it('should return empty when include matches nothing', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputFiles(ctx) + const ruleFiles = results.filter(r => r.path.includes('rule-')) + + expect(ruleFiles).toHaveLength(0) + }) + }) + + describe('registerProjectOutputDirs', () => { + it('should not register rules dir when all rules filtered out', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputDirs(ctx) + const rulesDirs = results.filter(r => r.path.includes('rules')) + + expect(rulesDirs).toHaveLength(0) + }) + + it('should register rules dir when rules match filter', async () => { + const rules = [ + createMockRulePrompt('test', 'rule1', 'uniapp', 'project') + ] + const projects = [ + createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) + ] + const ctx = createMockContext(tempDir, rules, projects) + + const results = await plugin.registerProjectOutputDirs(ctx) + const rulesDirs = results.filter(r => r.path.includes('rules')) + + expect(rulesDirs.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.property.test.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.property.test.ts new file mode 100644 index 00000000..ad133594 --- /dev/null +++ b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.property.test.ts @@ -0,0 +1,383 @@ +import type {OutputPluginContext, OutputWriteContext, RelativePath} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {createLogger, FilePathKind, PromptKind} from '@truenine/plugin-shared' +import * as fc from 'fast-check' +import {describe, it} from 'vitest' +import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { + return { + pathKind: FilePathKind.Relative, + path: pathStr, + basePath, + getDirectoryName: () => pathStr, + getAbsolutePath: () => path.join(basePath, pathStr) + } +} + +class TestableWindsurfOutputPlugin extends WindsurfOutputPlugin { + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } +} + +const validNameGen = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generators for property-based tests + .filter(s => /^[\w-]+$/.test(s)) + .map(s => s.toLowerCase()) + +const skillNameGen = validNameGen.filter(name => name.length > 0 && name !== 'create-rule' && name !== 'create-skill') + +const commandNameGen = validNameGen.filter(name => name.length > 0) + +const seriesNameGen = fc.option(validNameGen, {nil: void 0}) + +const fileContentGen = fc.string({minLength: 0, maxLength: 500}) + +describe('windsurf output plugin property tests', () => { + describe('registerGlobalOutputDirs', () => { + it('should always return empty array when no inputs provided', async () => { + await fc.assert( + fc.asyncProperty(fc.string({minLength: 1}), async _basePath => { + const plugin = new TestableWindsurfOutputPlugin() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) + plugin.setMockHomeDir(tempDir) + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + + fs.rmSync(tempDir, {recursive: true, force: true}) + return results.length === 0 + }) + ) + }) + + it('should always register at least one dir when fastCommands exist', async () => { + await fc.assert( + fc.asyncProperty( + commandNameGen, + seriesNameGen, + async (commandName, series) => { + const plugin = new TestableWindsurfOutputPlugin() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) + plugin.setMockHomeDir(tempDir) + + const fastCommand = { + type: PromptKind.FastCommand, + commandName, + series, + content: 'Test content', + length: 12, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', tempDir), + markdownContents: [], + yamlFrontMatter: {description: 'Test command', namingCase: 'kebab-case'} + } + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + fastCommands: [fastCommand], + skills: [] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + + fs.rmSync(tempDir, {recursive: true, force: true}) + return results.length >= 1 && results.some(r => r.path === 'global_workflows') + } + ) + ) + }) + + it('should always register at least one dir when skills exist', async () => { + await fc.assert( + fc.asyncProperty( + skillNameGen, + async skillName => { + const plugin = new TestableWindsurfOutputPlugin() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) + plugin.setMockHomeDir(tempDir) + + const skill = { + yamlFrontMatter: {name: skillName, description: 'Test skill', namingCase: 'kebab-case'}, + dir: createMockRelativePath(skillName, tempDir), + content: '# Test Skill', + length: 12, + type: PromptKind.Skill, + filePathKind: FilePathKind.Relative, + markdownContents: [] + } + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [skill], + fastCommands: [] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + + fs.rmSync(tempDir, {recursive: true, force: true}) + return results.length >= 1 && results.some(r => r.path.startsWith('skills')) + } + ) + ) + }) + }) + + describe('registerGlobalOutputFiles', () => { + it('should always return empty array when no inputs provided', async () => { + await fc.assert( + fc.asyncProperty(fc.string({minLength: 1}), async _basePath => { + const plugin = new TestableWindsurfOutputPlugin() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) + plugin.setMockHomeDir(tempDir) + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + + fs.rmSync(tempDir, {recursive: true, force: true}) + return results.length === 0 + }) + ) + }) + + it('should register one file per fastCommand', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(commandNameGen, {minLength: 1, maxLength: 5}), + async commandNames => { + const plugin = new TestableWindsurfOutputPlugin() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) + plugin.setMockHomeDir(tempDir) + + const fastCommands = commandNames.map(name => ({ + type: PromptKind.FastCommand, + commandName: name, + content: 'Test content', + length: 12, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', tempDir), + markdownContents: [], + yamlFrontMatter: {description: 'Test command', namingCase: 'kebab-case'} + })) + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + fastCommands, + skills: [] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + const workflowFiles = results.filter(r => r.path.startsWith('global_workflows')) + + fs.rmSync(tempDir, {recursive: true, force: true}) + return workflowFiles.length === commandNames.length + } + ) + ) + }) + }) + + describe('canWrite', () => { + it('should return true when any content exists', async () => { + await fc.assert( + fc.asyncProperty( + fc.boolean(), + fc.boolean(), + fc.boolean(), + async (hasSkills, hasFastCommands, hasGlobalMemory) => { + if (!hasSkills && !hasFastCommands && !hasGlobalMemory) return true // Skip if all are false + + const plugin = new TestableWindsurfOutputPlugin() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) + plugin.setMockHomeDir(tempDir) + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: hasSkills + ? [{yamlFrontMatter: {name: 'test-skill', description: 'Test', namingCase: 'kebab-case'}}] + : [], + fastCommands: hasFastCommands + ? [{commandName: 'test', yamlFrontMatter: {description: 'Test', namingCase: 'kebab-case'}}] + : [], + globalMemory: hasGlobalMemory + ? {content: 'Global rules', length: 12, type: PromptKind.GlobalMemory} + : null + } + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + + fs.rmSync(tempDir, {recursive: true, force: true}) + return result + } + ) + ) + }) + }) + + describe('writeGlobalOutputs dry-run property', () => { + it('should not modify filesystem when dryRun is true', async () => { + await fc.assert( + fc.asyncProperty( + fileContentGen, + async content => { + const plugin = new TestableWindsurfOutputPlugin() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) + plugin.setMockHomeDir(tempDir) + + const initialFiles = fs.existsSync(tempDir) // Capture initial state + ? fs.readdirSync(tempDir) + : [] + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + globalMemory: { + type: PromptKind.GlobalMemory, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', tempDir), + markdownContents: [] + }, + skills: [], + fastCommands: [] + }, + logger: createLogger('test', 'debug'), + dryRun: true + } as unknown as OutputWriteContext + + await plugin.writeGlobalOutputs(ctx) + + const finalFiles = fs.existsSync(tempDir) // Verify filesystem unchanged + ? fs.readdirSync(tempDir) + : [] + + fs.rmSync(tempDir, {recursive: true, force: true}) + return JSON.stringify(initialFiles) === JSON.stringify(finalFiles) + } + ) + ) + }) + }) + + describe('writeProjectOutputs', () => { + it('should always return empty results regardless of input', async () => { + await fc.assert( + fc.asyncProperty( + fc.boolean(), + fc.boolean(), + async (hasProjects, hasGlobalMemory) => { + const plugin = new TestableWindsurfOutputPlugin() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) + plugin.setMockHomeDir(tempDir) + + const projects = hasProjects + ? [{name: 'project-a', dirFromWorkspacePath: createMockRelativePath('project-a', tempDir)}] + : [] + + const ctx = { + collectedInputContext: { + workspace: {projects, directory: createMockRelativePath('.', tempDir)}, + globalMemory: hasGlobalMemory + ? {content: 'Global rules', length: 12, type: PromptKind.GlobalMemory} + : null + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeProjectOutputs(ctx) + + fs.rmSync(tempDir, {recursive: true, force: true}) + return results.files.length === 0 && results.dirs.length === 0 + } + ) + ) + }) + }) + + describe('output path consistency', () => { + it('should generate consistent base paths for all outputs', async () => { + await fc.assert( + fc.asyncProperty( + skillNameGen, + commandNameGen, + async (skillName, commandName) => { + const plugin = new TestableWindsurfOutputPlugin() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) + plugin.setMockHomeDir(tempDir) + + const skill = { + yamlFrontMatter: {name: skillName, description: 'Test skill', namingCase: 'kebab-case'}, + dir: createMockRelativePath(skillName, tempDir), + content: '# Test Skill', + length: 12, + type: PromptKind.Skill, + filePathKind: FilePathKind.Relative, + markdownContents: [] + } + + const fastCommand = { + type: PromptKind.FastCommand, + commandName, + content: 'Test content', + length: 12, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', tempDir), + markdownContents: [], + yamlFrontMatter: {description: 'Test command', namingCase: 'kebab-case'} + } + + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [skill], + fastCommands: [fastCommand] + } + } as unknown as OutputPluginContext + + const dirs = await plugin.registerGlobalOutputDirs(ctx) + const files = await plugin.registerGlobalOutputFiles(ctx) + + const basePaths = [...dirs, ...files].map(r => r.basePath) + const allSameBase = basePaths.every(bp => bp === basePaths[0]) + + fs.rmSync(tempDir, {recursive: true, force: true}) + return allSameBase && basePaths[0].includes('.codeium') + } + ) + ) + }) + }) +}) diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.test.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.test.ts new file mode 100644 index 00000000..40be0eb5 --- /dev/null +++ b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.test.ts @@ -0,0 +1,677 @@ +import type { + FastCommandPrompt, + GlobalMemoryPrompt, + OutputPluginContext, + OutputWriteContext, + RelativePath, + RulePrompt, + SkillPrompt +} from '@truenine/plugin-shared' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {createLogger, FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' + +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { + return { + pathKind: FilePathKind.Relative, + path: pathStr, + basePath, + getDirectoryName: () => pathStr, + getAbsolutePath: () => path.join(basePath, pathStr) + } +} + +function createMockRulePrompt( + series: string, + ruleName: string, + globs: readonly string[], + scope: 'global' | 'project', + seriName?: string +): RulePrompt { + const content = '# Rule body\n\nFollow this rule.' + return { + type: PromptKind.Rule, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', ''), + markdownContents: [], + yamlFrontMatter: { + description: 'Rule description', + globs, + namingCase: NamingCaseKind.KebabCase + }, + series, + ruleName, + globs, + scope, + ...seriName != null && {seriName} + } as RulePrompt +} + +function createMockGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt { + return { + type: PromptKind.GlobalMemory, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', basePath), + markdownContents: [], + parentDirectoryPath: { + type: 'UserHome', + directory: createMockRelativePath('.codeium/windsurf', basePath) + } + } as GlobalMemoryPrompt +} + +function createMockFastCommandPrompt( + commandName: string, + series?: string, + basePath = '' +): FastCommandPrompt { + const content = 'Run something' + return { + type: PromptKind.FastCommand, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('.', basePath), + markdownContents: [], + yamlFrontMatter: {description: 'Fast command', namingCase: NamingCaseKind.KebabCase}, + ...series != null && {series}, + commandName + } as FastCommandPrompt +} + +function createMockSkillPrompt( + name: string, + content = '# Skill', + basePath = '', + options?: {childDocs?: {relativePath: string, content: unknown}[], resources?: {relativePath: string, content: string, encoding: 'text' | 'base64'}[]} +): SkillPrompt { + return { + yamlFrontMatter: {name, description: 'A skill', namingCase: NamingCaseKind.KebabCase}, + dir: createMockRelativePath(name, basePath), + content, + length: content.length, + type: PromptKind.Skill, + filePathKind: FilePathKind.Relative, + markdownContents: [], + ...options + } as unknown as SkillPrompt +} + +class TestableWindsurfOutputPlugin extends WindsurfOutputPlugin { + private mockHomeDir: string | null = null + + public setMockHomeDir(dir: string | null): void { + this.mockHomeDir = dir + } + + protected override getHomeDir(): string { + if (this.mockHomeDir != null) return this.mockHomeDir + return super.getHomeDir() + } +} + +describe('windsurf output plugin', () => { + let tempDir: string, plugin: TestableWindsurfOutputPlugin + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-test-')) + plugin = new TestableWindsurfOutputPlugin() + plugin.setMockHomeDir(tempDir) + }) + + afterEach(() => { + if (tempDir != null && fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + catch { + } // ignore cleanup errors + } + }) + + describe('constructor', () => { + it('should have correct plugin name', () => expect(plugin.name).toBe('WindsurfOutputPlugin')) + + it('should depend on AgentsOutputPlugin', () => expect(plugin.dependsOn).toContain('AgentsOutputPlugin')) + }) + + describe('registerGlobalOutputDirs', () => { + it('should return empty when no fastCommands and no skills', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + expect(results).toHaveLength(0) + }) + + it('should register global_workflows dir when fastCommands exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [createMockFastCommandPrompt('compile', void 0, tempDir)] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + expect(results).toHaveLength(1) + expect(results[0]?.path).toBe('global_workflows') + expect(results[0]?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'global_workflows')) + }) + + it('should register skills/ dir when skills exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [createMockSkillPrompt('custom-skill', '# Skill', tempDir)] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + expect(results).toHaveLength(1) + expect(results[0]?.path).toBe(path.join('skills', 'custom-skill')) + expect(results[0]?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'skills', 'custom-skill')) + }) + + it('should register both workflows and skills dirs when both exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [createMockSkillPrompt('skill-a', '# Skill', tempDir)], + fastCommands: [createMockFastCommandPrompt('compile', void 0, tempDir)] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputDirs(ctx) + expect(results).toHaveLength(2) + const paths = results.map(r => r.path) + expect(paths).toContain('global_workflows') + expect(paths).toContain(path.join('skills', 'skill-a')) + }) + }) + + describe('registerGlobalOutputFiles', () => { + it('should return empty when no fastCommands and no skills', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results).toHaveLength(0) + }) + + it('should register workflow files under global_workflows/ when fastCommands exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [ + createMockFastCommandPrompt('compile', 'build', tempDir), + createMockFastCommandPrompt('test', void 0, tempDir) + ] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results.length).toBeGreaterThanOrEqual(2) + const paths = results.map(r => r.path) + expect(paths).toContain(path.join('global_workflows', 'build-compile.md')) + expect(paths).toContain(path.join('global_workflows', 'test.md')) + const compileEntry = results.find(r => r.path.includes('build-compile')) + expect(compileEntry?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'global_workflows', 'build-compile.md')) + }) + + it('should register skill files under skills//SKILL.md when skills exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [createMockSkillPrompt('my-skill', '# Skill', tempDir)] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results.some(r => r.path === path.join('skills', 'my-skill', 'SKILL.md'))).toBe(true) + }) + + it('should register childDocs when skills have them', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + createMockSkillPrompt('my-skill', '# Skill', tempDir, { + childDocs: [{relativePath: 'doc.cn.mdx', content: '# Child Doc'}] + }) + ] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results.some(r => r.path === path.join('skills', 'my-skill', 'doc.cn.md'))).toBe(true) + }) + + it('should register resources when skills have them', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + createMockSkillPrompt('my-skill', '# Skill', tempDir, { + resources: [{relativePath: 'resource.json', content: '{}', encoding: 'text'}] + }) + ] + } + } as unknown as OutputPluginContext + + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results.some(r => r.path === path.join('skills', 'my-skill', 'resource.json'))).toBe(true) + }) + }) + + describe('canWrite', () => { + it('should return true when skills exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [{yamlFrontMatter: {name: 's'}, dir: createMockRelativePath('s', tempDir)}] + } + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + expect(result).toBe(true) + }) + + it('should return false when no skills and no fastCommands and no globalMemory', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [], + globalMemory: null + } + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + expect(result).toBe(false) + }) + + it('should return true when only fastCommands exist', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [createMockFastCommandPrompt('lint', void 0, tempDir)], + globalMemory: null + } + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + expect(result).toBe(true) + }) + + it('should return true when only globalMemory exists', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [], + globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) + } + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + expect(result).toBe(true) + }) + + it('should return true when .codeiumignore exists in aiAgentIgnoreConfigFiles', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [], + globalMemory: null, + rules: [], + aiAgentIgnoreConfigFiles: [{fileName: '.codeiumignore', content: 'node_modules/'}] + } + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + expect(result).toBe(true) + }) + + it('should return false when only .codeignore (wrong name) exists in aiAgentIgnoreConfigFiles', async () => { // @see https://docs.windsurf.com/context-awareness/windsurf-ignore#windsurf-ignore + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [], + globalMemory: null, + rules: [], + aiAgentIgnoreConfigFiles: [{fileName: '.codeignore', content: 'node_modules/'}] + } + } as unknown as OutputWriteContext + + const result = await plugin.canWrite(ctx) + expect(result).toBe(false) + }) + }) + + describe('writeGlobalOutputs', () => { + it('should write global memory to ~/.codeium/windsurf/memories/global_rules.md', async () => { + const globalContent = '# Global Rules\n\nAlways apply these rules.' + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + globalMemory: createMockGlobalMemoryPrompt(globalContent, tempDir), + skills: [], + fastCommands: [] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeGlobalOutputs(ctx) + expect(results.files.length).toBeGreaterThanOrEqual(1) + expect(results.files[0]?.success).toBe(true) + + const memoryPath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'global_rules.md') + expect(fs.existsSync(memoryPath)).toBe(true) + const content = fs.readFileSync(memoryPath, 'utf8') + expect(content).toContain(globalContent) + }) + + it('should write fast command files to ~/.codeium/windsurf/global_workflows/', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [ + createMockFastCommandPrompt('compile', 'build', tempDir), + createMockFastCommandPrompt('test', void 0, tempDir) + ] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeGlobalOutputs(ctx) + expect(results.files).toHaveLength(2) + + const workflowsDir = path.join(tempDir, '.codeium', 'windsurf', 'global_workflows') + expect(fs.existsSync(workflowsDir)).toBe(true) + + const buildCompilePath = path.join(workflowsDir, 'build-compile.md') + const testPath = path.join(workflowsDir, 'test.md') + expect(fs.existsSync(buildCompilePath)).toBe(true) + expect(fs.existsSync(testPath)).toBe(true) + + const buildCompileContent = fs.readFileSync(buildCompilePath, 'utf8') + expect(buildCompileContent).toContain('Run something') + }) + + it('should write skill to ~/.codeium/windsurf/skills//SKILL.md', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [createMockSkillPrompt('my-skill', '# My Skill Content', tempDir)], + globalMemory: null, + fastCommands: [] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeGlobalOutputs(ctx) + expect(results.files.length).toBeGreaterThanOrEqual(1) + expect(results.files.every(f => f.success)).toBe(true) + + const skillPath = path.join(tempDir, '.codeium', 'windsurf', 'skills', 'my-skill', 'SKILL.md') + expect(fs.existsSync(skillPath)).toBe(true) + const content = fs.readFileSync(skillPath, 'utf8') + expect(content).toContain('name: my-skill') + expect(content).toContain('# My Skill Content') + }) + + it('should write childDocs when skills have them', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + createMockSkillPrompt('my-skill', '# Skill', tempDir, { + childDocs: [{relativePath: 'guide.cn.mdx', content: '# Guide Content'}] + }) + ], + globalMemory: null, + fastCommands: [] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + await plugin.writeGlobalOutputs(ctx) + + const childDocPath = path.join(tempDir, '.codeium', 'windsurf', 'skills', 'my-skill', 'guide.cn.md') + expect(fs.existsSync(childDocPath)).toBe(true) + const content = fs.readFileSync(childDocPath, 'utf8') + expect(content).toContain('# Guide Content') + }) + + it('should write resources when skills have them', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [ + createMockSkillPrompt('my-skill', '# Skill', tempDir, { + resources: [{relativePath: 'schema.json', content: '{"type": "object"}', encoding: 'text'}] + }) + ], + globalMemory: null, + fastCommands: [] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + await plugin.writeGlobalOutputs(ctx) + + const resourcePath = path.join(tempDir, '.codeium', 'windsurf', 'skills', 'my-skill', 'schema.json') + expect(fs.existsSync(resourcePath)).toBe(true) + const content = fs.readFileSync(resourcePath, 'utf8') + expect(content).toContain('{"type": "object"}') + }) + + it('should not write files on dryRun', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir), + skills: [], + fastCommands: [] + }, + logger: createLogger('test', 'debug'), + dryRun: true + } as unknown as OutputWriteContext + + const results = await plugin.writeGlobalOutputs(ctx) + expect(results.files.length).toBeGreaterThanOrEqual(1) + expect(results.files[0]?.success).toBe(true) + + const memoryPath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'global_rules.md') + expect(fs.existsSync(memoryPath)).toBe(false) + }) + + it('should write global rule files with trigger/globs frontmatter', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [], + fastCommands: [], + rules: [ + createMockRulePrompt('test', 'glob', ['src/**/*.ts', '**/*.tsx'], 'global') + ] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeGlobalOutputs(ctx) + expect(results.files).toHaveLength(1) + + const rulePath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'rule-test-glob.md') + expect(fs.existsSync(rulePath)).toBe(true) + + const content = fs.readFileSync(rulePath, 'utf8') + expect(content).toContain('trigger: glob') + expect(content).toContain('globs: src/**/*.ts, **/*.tsx') + expect(content).not.toContain('globs: "src/**/*.ts, **/*.tsx"') + expect(content).toContain('Follow this rule.') + }) + }) + + describe('writeProjectOutputs', () => { + it('should return empty results when no project rules', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeProjectOutputs(ctx) + expect(results.files).toHaveLength(0) + expect(results.dirs).toHaveLength(0) + }) + + it('should write .codeiumignore to project directories', async () => { + const projectDir = path.join(tempDir, 'my-project') + fs.mkdirSync(projectDir, {recursive: true}) + + const ctx = { + collectedInputContext: { + workspace: { + projects: [ + { + name: 'my-project', + dirFromWorkspacePath: createMockRelativePath('my-project', tempDir) + } + ], + directory: createMockRelativePath('.', tempDir) + }, + rules: [], + aiAgentIgnoreConfigFiles: [{fileName: '.codeiumignore', content: 'node_modules/\n.env\ndist/'}] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeProjectOutputs(ctx) + + const ignorePath = path.join(tempDir, 'my-project', '.codeiumignore') + expect(fs.existsSync(ignorePath)).toBe(true) + const content = fs.readFileSync(ignorePath, 'utf8') + expect(content).toContain('node_modules/') + expect(results.files.some(f => f.success)).toBe(true) + }) + + it('should not write .codeignore (wrong name) to project directories', async () => { + const projectDir = path.join(tempDir, 'my-project') + fs.mkdirSync(projectDir, {recursive: true}) + + const ctx = { + collectedInputContext: { + workspace: { + projects: [ + { + name: 'my-project', + dirFromWorkspacePath: createMockRelativePath('my-project', tempDir) + } + ], + directory: createMockRelativePath('.', tempDir) + }, + rules: [], + aiAgentIgnoreConfigFiles: [{fileName: '.codeignore', content: 'node_modules/'}] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + await plugin.writeProjectOutputs(ctx) + + const wrongIgnorePath = path.join(tempDir, 'my-project', '.codeignore') + const correctIgnorePath = path.join(tempDir, 'my-project', '.codeiumignore') + expect(fs.existsSync(wrongIgnorePath)).toBe(false) + expect(fs.existsSync(correctIgnorePath)).toBe(false) + }) + + it('should write project rules and apply seriName include filter from projectConfig', async () => { + const ctx = { + collectedInputContext: { + workspace: { + projects: [ + { + name: 'proj1', + dirFromWorkspacePath: createMockRelativePath('proj1', tempDir), + projectConfig: {rules: {includeSeries: ['uniapp']}} + } + ], + directory: createMockRelativePath('.', tempDir) + }, + rules: [ + createMockRulePrompt('test', 'uniapp-only', ['src/**/*.vue'], 'project', 'uniapp'), + createMockRulePrompt('test', 'vue-only', ['src/**/*.ts'], 'project', 'vue') + ] + }, + logger: createLogger('test', 'debug'), + dryRun: false + } as unknown as OutputWriteContext + + const results = await plugin.writeProjectOutputs(ctx) + const outputPaths = results.files.map(file => file.path.path.replaceAll('\\', '/')) + + expect(outputPaths.some(p => p.endsWith('rule-test-uniapp-only.md'))).toBe(true) + expect(outputPaths.some(p => p.endsWith('rule-test-vue-only.md'))).toBe(false) + + const includedRulePath = path.join(tempDir, 'proj1', '.windsurf', 'rules', 'rule-test-uniapp-only.md') + const excludedRulePath = path.join(tempDir, 'proj1', '.windsurf', 'rules', 'rule-test-vue-only.md') + + expect(fs.existsSync(includedRulePath)).toBe(true) + expect(fs.existsSync(excludedRulePath)).toBe(false) + + const includedRuleContent = fs.readFileSync(includedRulePath, 'utf8') + expect(includedRuleContent).toContain('trigger: glob') + expect(includedRuleContent).toContain('globs: src/**/*.vue') + }) + }) + + describe('clean support', () => { + it('should register global output dirs for cleanup', async () => { + const ctx = { + collectedInputContext: { + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, + skills: [createMockSkillPrompt('my-skill', '# Skill', tempDir)], + fastCommands: [createMockFastCommandPrompt('test', void 0, tempDir)] + } + } as unknown as OutputPluginContext + + const dirs = await plugin.registerGlobalOutputDirs(ctx) + expect(dirs.length).toBe(2) + + const files = await plugin.registerGlobalOutputFiles(ctx) + expect(files.length).toBeGreaterThanOrEqual(2) + }) + }) +}) diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts new file mode 100644 index 00000000..95d82f07 --- /dev/null +++ b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts @@ -0,0 +1,388 @@ +import type { + FastCommandPrompt, + OutputPluginContext, + OutputWriteContext, + RulePrompt, + SkillPrompt, + WriteResult, + WriteResults +} from '@truenine/plugin-shared' +import type {RelativePath} from '@truenine/plugin-shared/types' +import {Buffer} from 'node:buffer' +import * as fs from 'node:fs' +import * as path from 'node:path' +import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' +import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' +import {applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' + +const CODEIUM_WINDSURF_DIR = '.codeium/windsurf' +const WORKFLOWS_SUBDIR = 'global_workflows' +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-' + +export class WindsurfOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('WindsurfOutputPlugin', { + globalConfigDir: CODEIUM_WINDSURF_DIR, + outputFileName: '', + dependsOn: [PLUGIN_NAMES.AgentsOutput], + indexignore: '.codeiumignore' + }) + } + + async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {fastCommands, skills, rules} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + + if (fastCommands != null && fastCommands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + if (filteredCommands.length > 0) { + const workflowsDir = this.getGlobalWorkflowsDir() + results.push({pathKind: FilePathKind.Relative, path: WORKFLOWS_SUBDIR, basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => WORKFLOWS_SUBDIR, getAbsolutePath: () => workflowsDir}) + } + } + + if (skills != null && skills.length > 0) { + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter.name + const skillPath = path.join(this.getCodeiumWindsurfDir(), SKILLS_SUBDIR, skillName) + results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName), basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => skillName, getAbsolutePath: () => skillPath}) + } + } + + const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === '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 + } + + async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {skills, fastCommands} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + + if (fastCommands != null && fastCommands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + const workflowsDir = this.getGlobalWorkflowsDir() + const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + for (const cmd of filteredCommands) { + const fileName = this.transformFastCommandName(cmd, transformOptions) + const fullPath = path.join(workflowsDir, fileName) + results.push({pathKind: FilePathKind.Relative, path: path.join(WORKFLOWS_SUBDIR, fileName), basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => WORKFLOWS_SUBDIR, getAbsolutePath: () => fullPath}) + } + } + + const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === '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}) + } + } + + const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] + if (filteredSkills.length === 0) return results + + const skillsDir = this.getSkillsDir() + const codeiumDir = this.getCodeiumWindsurfDir() + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter.name + const skillDir = path.join(skillsDir, skillName) + results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, SKILL_FILE_NAME), basePath: codeiumDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, SKILL_FILE_NAME)}) + + if (skill.childDocs != null) { + for (const childDoc of skill.childDocs) { + const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') + results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, outputRelativePath), basePath: codeiumDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, outputRelativePath)}) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, resource.relativePath), basePath: codeiumDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, resource.relativePath)}) + } + } + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + 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 === '.codeiumignore') ?? false + + 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, rules} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + + if (globalMemory != null) fileResults.push(await this.writeGlobalMemory(ctx, globalMemory.content as string)) + + if (skills != null && skills.length > 0) { + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + const skillsDir = this.getSkillsDir() + for (const skill of filteredSkills) fileResults.push(...await this.writeGlobalSkill(ctx, skillsDir, skill)) + } + + if (fastCommands != null && fastCommands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + const workflowsDir = this.getGlobalWorkflowsDir() + for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalWorkflow(ctx, workflowsDir, cmd)) + } + + const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global') + if (globalRules == null || globalRules.length === 0) return {files: fileResults, dirs: dirResults} + + const memoriesDir = this.getGlobalMemoriesDir() + for (const rule of globalRules) fileResults.push(await this.writeRuleFile(ctx, memoriesDir, rule, this.getCodeiumWindsurfDir(), MEMORIES_SUBDIR)) + return {files: fileResults, dirs: dirResults} + } + + async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {workspace, rules} = ctx.collectedInputContext + if (rules == null || rules.length === 0) return results + + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + const projectRules = applySubSeriesGlobPrefix(filterRulesByProjectConfig(rules.filter(r => this.normalizeRuleScope(r) === 'project'), project.projectConfig), project.projectConfig) + if (projectRules.length === 0) 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 + + if (rules != null && rules.length > 0) { + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + const projectRules = applySubSeriesGlobPrefix(filterRulesByProjectConfig(rules.filter(r => this.normalizeRuleScope(r) === 'project'), project.projectConfig), project.projectConfig) + 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 + + if (rules != null && rules.length > 0) { + for (const project of workspace.projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + const projectRules = applySubSeriesGlobPrefix(filterRulesByProjectConfig(rules.filter(r => this.normalizeRuleScope(r) === 'project'), project.projectConfig), project.projectConfig) + if (projectRules.length === 0) continue + const rulesDir = path.join(projectDir.basePath, projectDir.path, WINDSURF_RULES_DIR, WINDSURF_RULES_SUBDIR) + for (const rule of projectRules) fileResults.push(await this.writeRuleFile(ctx, rulesDir, rule, projectDir.basePath, path.join(projectDir.path, WINDSURF_RULES_DIR, WINDSURF_RULES_SUBDIR))) + } + } + + fileResults.push(...await this.writeProjectIgnoreFiles(ctx)) + return {files: fileResults, dirs: []} + } + + private getSkillsDir(): string { return path.join(this.getCodeiumWindsurfDir(), SKILLS_SUBDIR) } + private getCodeiumWindsurfDir(): string { return path.join(this.getHomeDir(), CODEIUM_WINDSURF_DIR) } + private getGlobalMemoriesDir(): string { return path.join(this.getCodeiumWindsurfDir(), MEMORIES_SUBDIR) } + private getGlobalWorkflowsDir(): string { return path.join(this.getCodeiumWindsurfDir(), WORKFLOWS_SUBDIR) } + + private async writeGlobalMemory(ctx: OutputWriteContext, content: string): Promise { + const memoriesDir = this.getGlobalMemoriesDir() + const fullPath = path.join(memoriesDir, GLOBAL_MEMORY_FILE) + const codeiumDir = this.getCodeiumWindsurfDir() + const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(MEMORIES_SUBDIR, GLOBAL_MEMORY_FILE), basePath: codeiumDir, getDirectoryName: () => MEMORIES_SUBDIR, getAbsolutePath: () => fullPath} + + if (ctx.dryRun === true) { this.log.trace({action: 'dryRun', type: 'globalMemory', path: fullPath}); return {path: relativePath, success: true, skipped: false} } + + try { + this.ensureDirectory(memoriesDir) + this.writeFileSync(fullPath, content) + this.log.trace({action: 'write', type: 'globalMemory', path: fullPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'globalMemory', path: fullPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private async writeGlobalWorkflow(ctx: OutputWriteContext, workflowsDir: string, cmd: FastCommandPrompt): Promise { + const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + const fileName = this.transformFastCommandName(cmd, transformOptions) + const fullPath = path.join(workflowsDir, fileName) + const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(WORKFLOWS_SUBDIR, fileName), basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => WORKFLOWS_SUBDIR, getAbsolutePath: () => fullPath} + const content = this.buildMarkdownContentWithRaw(cmd.content, cmd.yamlFrontMatter, cmd.rawFrontMatter) + + if (ctx.dryRun === true) { this.log.trace({action: 'dryRun', type: 'globalWorkflow', path: fullPath}); return {path: relativePath, success: true, skipped: false} } + + try { + this.ensureDirectory(workflowsDir) + fs.writeFileSync(fullPath, content) + this.log.trace({action: 'write', type: 'globalWorkflow', path: fullPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'globalWorkflow', path: fullPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private async writeGlobalSkill(ctx: OutputWriteContext, skillsDir: string, skill: SkillPrompt): Promise { + const results: WriteResult[] = [] + const skillName = skill.yamlFrontMatter.name + const skillDir = path.join(skillsDir, skillName) + const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) + const codeiumDir = this.getCodeiumWindsurfDir() + const skillRelativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, SKILL_FILE_NAME), basePath: codeiumDir, getDirectoryName: () => skillName, getAbsolutePath: () => skillFilePath} + + const frontMatterData = this.buildSkillFrontMatter(skill) + const skillContent = buildMarkdownWithFrontMatter(frontMatterData, skill.content as string) + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'skill', path: skillFilePath}) + results.push({path: skillRelativePath, success: true, skipped: false}) + } else { + try { + this.ensureDirectory(skillDir) + this.writeFileSync(skillFilePath, skillContent) + this.log.trace({action: 'write', type: 'skill', path: skillFilePath}) + results.push({path: skillRelativePath, success: true}) + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'skill', path: skillFilePath, error: errMsg}) + results.push({path: skillRelativePath, success: false, error: error as Error}) + } + } + + if (skill.childDocs != null) { + for (const childDoc of skill.childDocs) results.push(await this.writeSkillChildDoc(ctx, childDoc, skillDir, skillName, codeiumDir)) + } + + if (skill.resources != null) { + for (const resource of skill.resources) results.push(await this.writeSkillResource(ctx, resource, skillDir, skillName, codeiumDir)) + } + + return results + } + + private buildSkillFrontMatter(skill: SkillPrompt): Record { + const fm = skill.yamlFrontMatter + return {name: fm.name, description: fm.description, ...fm.displayName != null && {displayName: fm.displayName}, ...fm.keywords != null && fm.keywords.length > 0 && {keywords: fm.keywords}, ...fm.author != null && {author: fm.author}, ...fm.version != null && {version: fm.version}, ...fm.allowTools != null && fm.allowTools.length > 0 && {allowTools: fm.allowTools}} + } + + private async writeSkillChildDoc(ctx: OutputWriteContext, childDoc: {relativePath: string, content: unknown}, skillDir: string, skillName: string, baseDir: string): Promise { + const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') + const childDocPath = path.join(skillDir, outputRelativePath) + const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, outputRelativePath), basePath: baseDir, getDirectoryName: () => skillName, getAbsolutePath: () => childDocPath} + const content = childDoc.content as string + + if (ctx.dryRun === true) { this.log.trace({action: 'dryRun', type: 'childDoc', path: childDocPath}); return {path: relativePath, success: true, skipped: false} } + + try { + this.ensureDirectory(path.dirname(childDocPath)) + this.writeFileSync(childDocPath, content) + this.log.trace({action: 'write', type: 'childDoc', path: childDocPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'childDoc', path: childDocPath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + private async writeSkillResource(ctx: OutputWriteContext, resource: {relativePath: string, content: string, encoding: 'text' | 'base64'}, skillDir: string, skillName: string, baseDir: string): Promise { + const resourcePath = path.join(skillDir, resource.relativePath) + const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, resource.relativePath), basePath: baseDir, getDirectoryName: () => skillName, getAbsolutePath: () => resourcePath} + + if (ctx.dryRun === true) { this.log.trace({action: 'dryRun', type: 'resource', path: resourcePath}); return {path: relativePath, success: true, skipped: false} } + + try { + this.ensureDirectory(path.dirname(resourcePath)) + if (resource.encoding === 'base64') this.writeFileSyncBuffer(resourcePath, Buffer.from(resource.content, 'base64')) + else this.writeFileSync(resourcePath, resource.content) + this.log.trace({action: 'write', type: 'resource', path: resourcePath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'resource', path: resourcePath, error: errMsg}) + 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 fmData: Record = {trigger: 'glob', globs: rule.globs.length > 0 ? rule.globs.join(', ') : ''} + const raw = buildMarkdownWithFrontMatter(fmData, rule.content) + const lines = raw.split('\n') + return lines.map(line => { + const match = /^(\s*globs:\s*)(['"])(.*)\2\s*$/.exec(line) + if (match == null) return line + const prefix = match[1] ?? 'globs: ' + const value = match[3] ?? '' + if (value.trim().length === 0) return line + return `${prefix}${value}` + }).join('\n') + } + + 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/plugin-windsurf/index.ts b/cli/src/plugins/plugin-windsurf/index.ts new file mode 100644 index 00000000..e749bd3d --- /dev/null +++ b/cli/src/plugins/plugin-windsurf/index.ts @@ -0,0 +1,3 @@ +export { + WindsurfOutputPlugin +} from './WindsurfOutputPlugin' diff --git a/cli/tsconfig.eslint.json b/cli/tsconfig.eslint.json index 585b38ee..56de5015 100644 --- a/cli/tsconfig.eslint.json +++ b/cli/tsconfig.eslint.json @@ -11,9 +11,7 @@ "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" + "tsdown.config.ts" ], "exclude": [ "../node_modules", diff --git a/cli/tsconfig.json b/cli/tsconfig.json index c31ab7f9..dfa88832 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -3,24 +3,70 @@ "compilerOptions": { "noUncheckedSideEffectImports": true, "incremental": true, - "composite": false, // Projects - "target": "ESNext", // Language and Environment + "composite": false, + "target": "ESNext", "lib": [ "ESNext" ], "moduleDetection": "force", "useDefineForClassFields": true, - "baseUrl": ".", // Path Mapping - "module": "ESNext", // Module Resolution + "baseUrl": ".", + "module": "ESNext", "moduleResolution": "Bundler", "paths": { "@/*": [ "./src/*" - ] + ], + "@truenine/desk-paths": ["./src/plugins/desk-paths/index.ts"], + "@truenine/desk-paths/*": ["./src/plugins/desk-paths/*"], + "@truenine/plugin-shared": ["./src/plugins/plugin-shared/index.ts"], + "@truenine/plugin-shared/*": ["./src/plugins/plugin-shared/*"], + "@truenine/plugin-output-shared": ["./src/plugins/plugin-output-shared/index.ts"], + "@truenine/plugin-output-shared/*": ["./src/plugins/plugin-output-shared/*"], + "@truenine/plugin-input-shared": ["./src/plugins/plugin-input-shared/index.ts"], + "@truenine/plugin-input-shared/*": ["./src/plugins/plugin-input-shared/*"], + "@truenine/plugin-agentskills-compact": ["./src/plugins/plugin-agentskills-compact/index.ts"], + "@truenine/plugin-agentsmd": ["./src/plugins/plugin-agentsmd/index.ts"], + "@truenine/plugin-antigravity": ["./src/plugins/plugin-antigravity/index.ts"], + "@truenine/plugin-claude-code-cli": ["./src/plugins/plugin-claude-code-cli/index.ts"], + "@truenine/plugin-cursor": ["./src/plugins/plugin-cursor/index.ts"], + "@truenine/plugin-droid-cli": ["./src/plugins/plugin-droid-cli/index.ts"], + "@truenine/plugin-editorconfig": ["./src/plugins/plugin-editorconfig/index.ts"], + "@truenine/plugin-gemini-cli": ["./src/plugins/plugin-gemini-cli/index.ts"], + "@truenine/plugin-git-exclude": ["./src/plugins/plugin-git-exclude/index.ts"], + "@truenine/plugin-input-agentskills": ["./src/plugins/plugin-input-agentskills/index.ts"], + "@truenine/plugin-input-editorconfig": ["./src/plugins/plugin-input-editorconfig/index.ts"], + "@truenine/plugin-input-fast-command": ["./src/plugins/plugin-input-fast-command/index.ts"], + "@truenine/plugin-input-git-exclude": ["./src/plugins/plugin-input-git-exclude/index.ts"], + "@truenine/plugin-input-gitignore": ["./src/plugins/plugin-input-gitignore/index.ts"], + "@truenine/plugin-input-global-memory": ["./src/plugins/plugin-input-global-memory/index.ts"], + "@truenine/plugin-input-jetbrains-config": ["./src/plugins/plugin-input-jetbrains-config/index.ts"], + "@truenine/plugin-input-md-cleanup-effect": ["./src/plugins/plugin-input-md-cleanup-effect/index.ts"], + "@truenine/plugin-input-orphan-cleanup-effect": ["./src/plugins/plugin-input-orphan-cleanup-effect/index.ts"], + "@truenine/plugin-input-project-prompt": ["./src/plugins/plugin-input-project-prompt/index.ts"], + "@truenine/plugin-input-readme": ["./src/plugins/plugin-input-readme/index.ts"], + "@truenine/plugin-input-rule": ["./src/plugins/plugin-input-rule/index.ts"], + "@truenine/plugin-input-shadow-project": ["./src/plugins/plugin-input-shadow-project/index.ts"], + "@truenine/plugin-input-shared-ignore": ["./src/plugins/plugin-input-shared-ignore/index.ts"], + "@truenine/plugin-input-skill-sync-effect": ["./src/plugins/plugin-input-skill-sync-effect/index.ts"], + "@truenine/plugin-input-subagent": ["./src/plugins/plugin-input-subagent/index.ts"], + "@truenine/plugin-input-vscode-config": ["./src/plugins/plugin-input-vscode-config/index.ts"], + "@truenine/plugin-input-workspace": ["./src/plugins/plugin-input-workspace/index.ts"], + "@truenine/plugin-jetbrains-ai-codex": ["./src/plugins/plugin-jetbrains-ai-codex/index.ts"], + "@truenine/plugin-jetbrains-codestyle": ["./src/plugins/plugin-jetbrains-codestyle/index.ts"], + "@truenine/plugin-kiro-ide": ["./src/plugins/plugin-kiro-ide/index.ts"], + "@truenine/plugin-openai-codex-cli": ["./src/plugins/plugin-openai-codex-cli/index.ts"], + "@truenine/plugin-opencode-cli": ["./src/plugins/plugin-opencode-cli/index.ts"], + "@truenine/plugin-qoder-ide": ["./src/plugins/plugin-qoder-ide/index.ts"], + "@truenine/plugin-readme": ["./src/plugins/plugin-readme/index.ts"], + "@truenine/plugin-trae-ide": ["./src/plugins/plugin-trae-ide/index.ts"], + "@truenine/plugin-vscode": ["./src/plugins/plugin-vscode/index.ts"], + "@truenine/plugin-warp-ide": ["./src/plugins/plugin-warp-ide/index.ts"], + "@truenine/plugin-windsurf": ["./src/plugins/plugin-windsurf/index.ts"] }, "resolveJsonModule": true, "allowImportingTsExtensions": true, - "strict": true, // Type Checking - Maximum Strictness + "strict": true, "strictBindCallApply": true, "strictFunctionTypes": true, "strictNullChecks": true, @@ -39,7 +85,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "useUnknownInCatchVariables": true, - "declaration": true, // Emit + "declaration": true, "declarationMap": true, "importHelpers": true, "newLine": "lf", @@ -49,19 +95,17 @@ "sourceMap": true, "stripInternal": true, "allowSyntheticDefaultImports": true, - "esModuleInterop": true, // Interop Constraints + "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, "verbatimModuleSyntax": true, - "skipLibCheck": true // Completeness + "skipLibCheck": true }, "include": [ "src/**/*", "env.d.ts", "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" + "tsdown.config.ts" ], "exclude": [ "../node_modules", diff --git a/cli/tsconfig.lib.json b/cli/tsconfig.lib.json index b2449b37..5597f4de 100644 --- a/cli/tsconfig.lib.json +++ b/cli/tsconfig.lib.json @@ -3,14 +3,15 @@ "extends": "./tsconfig.json", "compilerOptions": { "composite": true, - "rootDir": "./src", + "rootDir": ".", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, "include": [ "src/**/*", - "env.d.ts" + "env.d.ts", + "tsdown.config.ts" ], "exclude": [ "../node_modules", diff --git a/cli/tsdown.config.ts b/cli/tsdown.config.ts index f5e144e3..9f55cd31 100644 --- a/cli/tsdown.config.ts +++ b/cli/tsdown.config.ts @@ -1,12 +1,65 @@ -import {readFileSync, writeFileSync} from 'node:fs' +import {readFileSync} from 'node:fs' import {resolve} from 'node:path' import {bundles} from '@truenine/init-bundle' import {defineConfig} from 'tsdown' -import {TNMSC_JSON_SCHEMA} from './src/schema.ts' const pkg = JSON.parse(readFileSync('./package.json', 'utf8')) as {version: string, name: string} const kiroGlobalPowersRegistry = bundles['public/kiro_global_powers_registry.json']?.content ?? '{"version":"1.0.0","powers":{},"repoSources":{}}' +const pluginAliases: Record = { + '@truenine/desk-paths': resolve('src/plugins/desk-paths/index.ts'), + '@truenine/plugin-shared': resolve('src/plugins/plugin-shared/index.ts'), + '@truenine/plugin-output-shared': resolve('src/plugins/plugin-output-shared/index.ts'), + '@truenine/plugin-input-shared': resolve('src/plugins/plugin-input-shared/index.ts'), + '@truenine/plugin-agentskills-compact': resolve('src/plugins/plugin-agentskills-compact/index.ts'), + '@truenine/plugin-agentsmd': resolve('src/plugins/plugin-agentsmd/index.ts'), + '@truenine/plugin-antigravity': resolve('src/plugins/plugin-antigravity/index.ts'), + '@truenine/plugin-claude-code-cli': resolve('src/plugins/plugin-claude-code-cli/index.ts'), + '@truenine/plugin-cursor': resolve('src/plugins/plugin-cursor/index.ts'), + '@truenine/plugin-droid-cli': resolve('src/plugins/plugin-droid-cli/index.ts'), + '@truenine/plugin-editorconfig': resolve('src/plugins/plugin-editorconfig/index.ts'), + '@truenine/plugin-gemini-cli': resolve('src/plugins/plugin-gemini-cli/index.ts'), + '@truenine/plugin-git-exclude': resolve('src/plugins/plugin-git-exclude/index.ts'), + '@truenine/plugin-input-agentskills': resolve('src/plugins/plugin-input-agentskills/index.ts'), + '@truenine/plugin-input-editorconfig': resolve('src/plugins/plugin-input-editorconfig/index.ts'), + '@truenine/plugin-input-fast-command': resolve('src/plugins/plugin-input-fast-command/index.ts'), + '@truenine/plugin-input-git-exclude': resolve('src/plugins/plugin-input-git-exclude/index.ts'), + '@truenine/plugin-input-gitignore': resolve('src/plugins/plugin-input-gitignore/index.ts'), + '@truenine/plugin-input-global-memory': resolve('src/plugins/plugin-input-global-memory/index.ts'), + '@truenine/plugin-input-jetbrains-config': resolve('src/plugins/plugin-input-jetbrains-config/index.ts'), + '@truenine/plugin-input-md-cleanup-effect': resolve('src/plugins/plugin-input-md-cleanup-effect/index.ts'), + '@truenine/plugin-input-orphan-cleanup-effect': resolve('src/plugins/plugin-input-orphan-cleanup-effect/index.ts'), + '@truenine/plugin-input-project-prompt': resolve('src/plugins/plugin-input-project-prompt/index.ts'), + '@truenine/plugin-input-readme': resolve('src/plugins/plugin-input-readme/index.ts'), + '@truenine/plugin-input-rule': resolve('src/plugins/plugin-input-rule/index.ts'), + '@truenine/plugin-input-shadow-project': resolve('src/plugins/plugin-input-shadow-project/index.ts'), + '@truenine/plugin-input-shared-ignore': resolve('src/plugins/plugin-input-shared-ignore/index.ts'), + '@truenine/plugin-input-skill-sync-effect': resolve('src/plugins/plugin-input-skill-sync-effect/index.ts'), + '@truenine/plugin-input-subagent': resolve('src/plugins/plugin-input-subagent/index.ts'), + '@truenine/plugin-input-vscode-config': resolve('src/plugins/plugin-input-vscode-config/index.ts'), + '@truenine/plugin-input-workspace': resolve('src/plugins/plugin-input-workspace/index.ts'), + '@truenine/plugin-jetbrains-ai-codex': resolve('src/plugins/plugin-jetbrains-ai-codex/index.ts'), + '@truenine/plugin-jetbrains-codestyle': resolve('src/plugins/plugin-jetbrains-codestyle/index.ts'), + '@truenine/plugin-kiro-ide': resolve('src/plugins/plugin-kiro-ide/index.ts'), + '@truenine/plugin-openai-codex-cli': resolve('src/plugins/plugin-openai-codex-cli/index.ts'), + '@truenine/plugin-opencode-cli': resolve('src/plugins/plugin-opencode-cli/index.ts'), + '@truenine/plugin-qoder-ide': resolve('src/plugins/plugin-qoder-ide/index.ts'), + '@truenine/plugin-readme': resolve('src/plugins/plugin-readme/index.ts'), + '@truenine/plugin-trae-ide': resolve('src/plugins/plugin-trae-ide/index.ts'), + '@truenine/plugin-vscode': resolve('src/plugins/plugin-vscode/index.ts'), + '@truenine/plugin-warp-ide': resolve('src/plugins/plugin-warp-ide/index.ts'), + '@truenine/plugin-windsurf': resolve('src/plugins/plugin-windsurf/index.ts') +} + +const noExternalDeps = [ + '@truenine/logger', + 'fast-glob', + '@truenine/desk-paths', + '@truenine/init-bundle', + '@truenine/md-compiler', + ...Object.keys(pluginAliases) +] + export default defineConfig([ { entry: ['./src/index.ts', '!**/*.{spec,test}.*'], @@ -15,15 +68,10 @@ export default defineConfig([ unbundle: false, inlineOnly: false, alias: { - '@': resolve('src') + '@': resolve('src'), + ...pluginAliases }, - noExternal: [ - '@truenine/logger', - 'fast-glob', - '@truenine/desk-paths', - '@truenine/init-bundle', - '@truenine/md-compiler' - ], + noExternal: noExternalDeps, format: ['esm', 'cjs'], minify: true, dts: false, @@ -32,11 +80,6 @@ export default defineConfig([ __CLI_VERSION__: JSON.stringify(pkg.version), __CLI_PACKAGE_NAME__: JSON.stringify(pkg.name), __KIRO_GLOBAL_POWERS_REGISTRY__: kiroGlobalPowersRegistry - }, - hooks: { - 'build:done'() { - writeFileSync('./dist/tnmsc.schema.json', `${JSON.stringify(TNMSC_JSON_SCHEMA, null, 2)}\n`, 'utf8') - } } }, { @@ -46,15 +89,10 @@ export default defineConfig([ unbundle: false, inlineOnly: false, alias: { - '@': resolve('src') + '@': resolve('src'), + ...pluginAliases }, - noExternal: [ - '@truenine/logger', - 'fast-glob', - '@truenine/desk-paths', - '@truenine/init-bundle', - '@truenine/md-compiler' - ], + noExternal: noExternalDeps, format: ['esm'], minify: true, dts: false, @@ -69,7 +107,8 @@ export default defineConfig([ platform: 'node', sourcemap: false, alias: { - '@': resolve('src') + '@': resolve('src'), + ...pluginAliases }, format: ['esm', 'cjs'], minify: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a958a12..5dd5c694 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,9 +258,6 @@ importers: specifier: 'catalog:' version: 4.3.6 devDependencies: - '@truenine/desk-paths': - specifier: workspace:* - version: link:../packages/desk-paths '@truenine/init-bundle': specifier: workspace:* version: link:../libraries/init-bundle @@ -270,129 +267,6 @@ importers: '@truenine/md-compiler': specifier: workspace:* version: link:../libraries/md-compiler - '@truenine/plugin-agentskills-compact': - specifier: workspace:* - version: link:../packages/plugin-agentskills-compact - '@truenine/plugin-agentsmd': - specifier: workspace:* - version: link:../packages/plugin-agentsmd - '@truenine/plugin-antigravity': - specifier: workspace:* - version: link:../packages/plugin-antigravity - '@truenine/plugin-claude-code-cli': - specifier: workspace:* - version: link:../packages/plugin-claude-code-cli - '@truenine/plugin-cursor': - specifier: workspace:* - version: link:../packages/plugin-cursor - '@truenine/plugin-droid-cli': - specifier: workspace:* - version: link:../packages/plugin-droid-cli - '@truenine/plugin-editorconfig': - specifier: workspace:* - version: link:../packages/plugin-editorconfig - '@truenine/plugin-gemini-cli': - specifier: workspace:* - version: link:../packages/plugin-gemini-cli - '@truenine/plugin-git-exclude': - specifier: workspace:* - version: link:../packages/plugin-git-exclude - '@truenine/plugin-input-agentskills': - specifier: workspace:* - version: link:../packages/plugin-input-agentskills - '@truenine/plugin-input-editorconfig': - specifier: workspace:* - version: link:../packages/plugin-input-editorconfig - '@truenine/plugin-input-fast-command': - specifier: workspace:* - version: link:../packages/plugin-input-fast-command - '@truenine/plugin-input-git-exclude': - specifier: workspace:* - version: link:../packages/plugin-input-git-exclude - '@truenine/plugin-input-gitignore': - specifier: workspace:* - version: link:../packages/plugin-input-gitignore - '@truenine/plugin-input-global-memory': - specifier: workspace:* - version: link:../packages/plugin-input-global-memory - '@truenine/plugin-input-jetbrains-config': - specifier: workspace:* - version: link:../packages/plugin-input-jetbrains-config - '@truenine/plugin-input-md-cleanup-effect': - specifier: workspace:* - version: link:../packages/plugin-input-md-cleanup-effect - '@truenine/plugin-input-orphan-cleanup-effect': - specifier: workspace:* - version: link:../packages/plugin-input-orphan-cleanup-effect - '@truenine/plugin-input-project-prompt': - specifier: workspace:* - version: link:../packages/plugin-input-project-prompt - '@truenine/plugin-input-readme': - specifier: workspace:* - version: link:../packages/plugin-input-readme - '@truenine/plugin-input-rule': - specifier: workspace:* - version: link:../packages/plugin-input-rule - '@truenine/plugin-input-shadow-project': - specifier: workspace:* - version: link:../packages/plugin-input-shadow-project - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../packages/plugin-input-shared - '@truenine/plugin-input-shared-ignore': - specifier: workspace:* - version: link:../packages/plugin-input-shared-ignore - '@truenine/plugin-input-skill-sync-effect': - specifier: workspace:* - version: link:../packages/plugin-input-skill-sync-effect - '@truenine/plugin-input-subagent': - specifier: workspace:* - version: link:../packages/plugin-input-subagent - '@truenine/plugin-input-vscode-config': - specifier: workspace:* - version: link:../packages/plugin-input-vscode-config - '@truenine/plugin-input-workspace': - specifier: workspace:* - version: link:../packages/plugin-input-workspace - '@truenine/plugin-jetbrains-ai-codex': - specifier: workspace:* - version: link:../packages/plugin-jetbrains-ai-codex - '@truenine/plugin-jetbrains-codestyle': - specifier: workspace:* - version: link:../packages/plugin-jetbrains-codestyle - '@truenine/plugin-kiro-ide': - specifier: workspace:* - version: link:../packages/plugin-kiro-ide - '@truenine/plugin-openai-codex-cli': - specifier: workspace:* - version: link:../packages/plugin-openai-codex-cli - '@truenine/plugin-opencode-cli': - specifier: workspace:* - version: link:../packages/plugin-opencode-cli - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../packages/plugin-output-shared - '@truenine/plugin-qoder-ide': - specifier: workspace:* - version: link:../packages/plugin-qoder-ide - '@truenine/plugin-readme': - specifier: workspace:* - version: link:../packages/plugin-readme - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../packages/plugin-shared - '@truenine/plugin-trae-ide': - specifier: workspace:* - version: link:../packages/plugin-trae-ide - '@truenine/plugin-vscode': - specifier: workspace:* - version: link:../packages/plugin-vscode - '@truenine/plugin-warp-ide': - specifier: workspace:* - version: link:../packages/plugin-warp-ide - '@truenine/plugin-windsurf': - specifier: workspace:* - version: link:../packages/plugin-windsurf '@types/fs-extra': specifier: 'catalog:' version: 11.0.4 @@ -698,493 +572,6 @@ importers: specifier: 'catalog:' version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) - packages/desk-paths: {} - - packages/plugin-agentskills-compact: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-agentsmd: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-antigravity: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-claude-code-cli: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-cursor: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - picomatch: - specifier: 'catalog:' - version: 4.0.3 - - packages/plugin-droid-cli: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-editorconfig: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-gemini-cli: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-git-exclude: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-agentskills: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-editorconfig: - devDependencies: - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-fast-command: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-git-exclude: - devDependencies: - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-gitignore: - devDependencies: - '@truenine/init-bundle': - specifier: workspace:* - version: link:../../libraries/init-bundle - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-global-memory: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-jetbrains-config: - devDependencies: - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-md-cleanup-effect: - devDependencies: - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - fast-glob: - specifier: 'catalog:' - version: 3.3.3 - - packages/plugin-input-orphan-cleanup-effect: - devDependencies: - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - fast-glob: - specifier: 'catalog:' - version: 3.3.3 - - packages/plugin-input-project-prompt: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-readme: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-rule: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-shadow-project: - devDependencies: - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - jsonc-parser: - specifier: 'catalog:' - version: 3.3.1 - - packages/plugin-input-shared: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - fast-glob: - specifier: 'catalog:' - version: 3.3.3 - - packages/plugin-input-shared-ignore: - devDependencies: - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-skill-sync-effect: - devDependencies: - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - fast-glob: - specifier: 'catalog:' - version: 3.3.3 - - packages/plugin-input-subagent: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-vscode-config: - devDependencies: - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-input-workspace: - devDependencies: - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-jetbrains-ai-codex: - devDependencies: - '@truenine/desk-paths': - specifier: workspace:* - version: link:../desk-paths - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-jetbrains-codestyle: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-kiro-ide: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - picomatch: - specifier: 'catalog:' - version: 4.0.3 - - packages/plugin-openai-codex-cli: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-opencode-cli: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - jsonc-parser: - specifier: 'catalog:' - version: 3.3.1 - - packages/plugin-output-shared: - dependencies: - '@truenine/config': - specifier: workspace:* - version: link:../../libraries/config - picomatch: - specifier: 'catalog:' - version: 4.0.3 - devDependencies: - '@truenine/desk-paths': - specifier: workspace:* - version: link:../desk-paths - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-input-shared': - specifier: workspace:* - version: link:../plugin-input-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - fast-check: - specifier: 'catalog:' - version: 4.5.3 - fast-glob: - specifier: 'catalog:' - version: 3.3.3 - - packages/plugin-qoder-ide: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - picomatch: - specifier: 'catalog:' - version: 4.0.3 - - packages/plugin-readme: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-shared: - dependencies: - zod: - specifier: 'catalog:' - version: 4.3.6 - devDependencies: - '@truenine/init-bundle': - specifier: workspace:* - version: link:../../libraries/init-bundle - '@truenine/logger': - specifier: workspace:* - version: link:../../libraries/logger - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - fast-check: - specifier: 'catalog:' - version: 4.5.3 - fast-glob: - specifier: 'catalog:' - version: 3.3.3 - - packages/plugin-trae-ide: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-vscode: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-warp-ide: - devDependencies: - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - - packages/plugin-windsurf: - devDependencies: - '@truenine/md-compiler': - specifier: workspace:* - version: link:../../libraries/md-compiler - '@truenine/plugin-output-shared': - specifier: workspace:* - version: link:../plugin-output-shared - '@truenine/plugin-shared': - specifier: workspace:* - version: link:../plugin-shared - picomatch: - specifier: 'catalog:' - version: 4.0.3 - packages: '@antfu/eslint-config@6.7.1': From ed355277880ac5c0875ac88ae1f4c94a37845ed9 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Sun, 1 Mar 2026 12:37:40 +0800 Subject: [PATCH 03/10] =?UTF-8?q?refactor(=E6=8F=92=E4=BB=B6):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=8F=92=E4=BB=B6=E6=A8=A1=E5=9D=97=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=92=8C=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除废弃的Kiro插件相关代码和配置 - 重构插件模块导入路径,统一使用index.ts导出 - 更新测试用例中glob路径的匹配规则,支持带引号和不带引号格式 - 添加vite和vitest配置文件到tsconfig排除列表 - 优化插件类型定义,增加FastGlobType类型 - 更新vite配置中的插件别名路径 --- cli/src/PluginPipeline.ts | 4 +- cli/src/compiler-integration.test.ts | 3 +- cli/src/config.ts | 2 +- cli/src/plugin-runtime.ts | 2 +- cli/src/plugin.config.ts | 2 - ...eCodeCLIOutputPlugin.projectConfig.test.ts | 2 +- ...ClaudeCodeCLIOutputPlugin.property.test.ts | 6 +-- .../ClaudeCodeCLIOutputPlugin.test.ts | 10 ++-- .../ClaudeCodeCLIOutputPlugin.ts | 3 +- .../CursorOutputPlugin.projectConfig.test.ts | 2 +- .../plugin-cursor/CursorOutputPlugin.ts | 3 +- .../GitExcludeOutputPlugin.ts | 3 +- cli/src/plugins/plugin-input-shared/index.ts | 5 ++ .../JetBrainsAIAssistantCodexOutputPlugin.ts | 3 +- .../CodexCLIOutputPlugin.ts | 3 +- ...ncodeCLIOutputPlugin.projectConfig.test.ts | 2 +- .../OpencodeCLIOutputPlugin.property.test.ts | 2 +- .../OpencodeCLIOutputPlugin.test.ts | 4 +- .../OpencodeCLIOutputPlugin.ts | 3 +- .../BaseCLIOutputPlugin.ts | 2 +- cli/src/plugins/plugin-output-shared/index.ts | 12 +++++ ...DEPluginOutputPlugin.projectConfig.test.ts | 2 +- .../QoderIDEPluginOutputPlugin.ts | 3 +- cli/src/plugins/plugin-shared/index.ts | 5 ++ .../plugin-shared/types/PluginTypes.ts | 6 ++- .../plugin-trae-ide/TraeIDEOutputPlugin.ts | 3 +- ...WindsurfOutputPlugin.projectConfig.test.ts | 2 +- .../plugin-windsurf/WindsurfOutputPlugin.ts | 3 +- cli/src/utils/ruleFilter.ts | 2 +- cli/tsconfig.eslint.json | 4 +- cli/tsconfig.json | 8 +-- cli/tsdown.config.ts | 4 +- cli/vite.config.ts | 54 ++++++++++++++++++- cli/vitest.config.ts | 4 +- 34 files changed, 123 insertions(+), 55 deletions(-) diff --git a/cli/src/PluginPipeline.ts b/cli/src/PluginPipeline.ts index fbd9c828..ae3361eb 100644 --- a/cli/src/PluginPipeline.ts +++ b/cli/src/PluginPipeline.ts @@ -4,9 +4,9 @@ import type {Command, CommandContext} from '@/commands' import type {PipelineConfig} from '@/config' import * as fs from 'node:fs' import * as path from 'node:path' -import {GlobalScopeCollector, ScopePriority, ScopeRegistry} from '@truenine/plugin-input-shared/scope' +import {GlobalScopeCollector, ScopePriority, ScopeRegistry} from '@truenine/plugin-input-shared' import {CircularDependencyError, createLogger, MissingDependencyError, setGlobalLogLevel} from '@truenine/plugin-shared' -import * as glob from 'fast-glob' +import glob from 'fast-glob' import { CleanCommand, ConfigCommand, diff --git a/cli/src/compiler-integration.test.ts b/cli/src/compiler-integration.test.ts index ef9aad70..ca26761e 100644 --- a/cli/src/compiler-integration.test.ts +++ b/cli/src/compiler-integration.test.ts @@ -13,8 +13,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import {clearComponents, mdxToMd, registerBuiltInComponents} from '@truenine/md-compiler' import {ShellKind} from '@truenine/md-compiler/globals' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {GlobalScopeCollector, ScopePriority, ScopeRegistry} from '@truenine/plugin-input-shared/scope' +import {AbstractInputPlugin, GlobalScopeCollector, ScopePriority, ScopeRegistry} from '@truenine/plugin-input-shared' import {createLogger} from '@truenine/plugin-shared' import glob from 'fast-glob' import {afterEach, beforeEach, describe, expect, it} from 'vitest' diff --git a/cli/src/config.ts b/cli/src/config.ts index 46df89d3..41bb023a 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import process from 'node:process' import {createLogger, DEFAULT_USER_CONFIG, PluginKind} from '@truenine/plugin-shared' -import * as glob from 'fast-glob' +import glob from 'fast-glob' import {ensureShadowProjectConfigLink, loadUserConfig, validateAndEnsureGlobalConfig} from './ConfigLoader' import {PluginPipeline} from './PluginPipeline' import {checkVersionControl} from './ShadowSourceProject' diff --git a/cli/src/plugin-runtime.ts b/cli/src/plugin-runtime.ts index 9182f7c5..b8c2e967 100644 --- a/cli/src/plugin-runtime.ts +++ b/cli/src/plugin-runtime.ts @@ -16,7 +16,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import process from 'node:process' import {createLogger, setGlobalLogLevel} from '@truenine/plugin-shared' -import * as glob from 'fast-glob' +import glob from 'fast-glob' import { CleanCommand, DryRunCleanCommand, diff --git a/cli/src/plugin.config.ts b/cli/src/plugin.config.ts index 4dfaeb01..1657877d 100644 --- a/cli/src/plugin.config.ts +++ b/cli/src/plugin.config.ts @@ -27,7 +27,6 @@ import {VSCodeConfigInputPlugin} from '@truenine/plugin-input-vscode-config' import {WorkspaceInputPlugin} from '@truenine/plugin-input-workspace' import {JetBrainsAIAssistantCodexOutputPlugin} from '@truenine/plugin-jetbrains-ai-codex' import {JetBrainsIDECodeStyleConfigOutputPlugin} from '@truenine/plugin-jetbrains-codestyle' -import {KiroCLIOutputPlugin} from '@truenine/plugin-kiro-ide' import {CodexCLIOutputPlugin} from '@truenine/plugin-openai-codex-cli' import {OpencodeCLIOutputPlugin} from '@truenine/plugin-opencode-cli' import {QoderIDEPluginOutputPlugin} from '@truenine/plugin-qoder-ide' @@ -48,7 +47,6 @@ export default defineConfig({ new DroidCLIOutputPlugin(), new GeminiCLIOutputPlugin(), new GenericSkillsOutputPlugin(), - new KiroCLIOutputPlugin(), new OpencodeCLIOutputPlugin(), new QoderIDEPluginOutputPlugin(), new TraeIDEOutputPlugin(), diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts index 3d20ad39..442b8f95 100644 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts @@ -2,7 +2,7 @@ import type {OutputPluginContext} from '@truenine/plugin-shared' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' +import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts index bcc4d54c..8e1c8e80 100644 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts @@ -116,7 +116,7 @@ describe('claudeCodeCLIOutputPlugin property tests', () => { await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { const rule = createMockRulePrompt({series, ruleName, globs, content}) const output = plugin.testBuildRuleContent(rule) - for (const g of globs) expect(output).toContain(`- "${g}"`) + for (const g of globs) expect(output).toMatch(new RegExp(`-\\s+['"]?${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?`)) // Accept quoted or unquoted formats }), {numRuns: 100}) }) }) @@ -133,7 +133,7 @@ describe('claudeCodeCLIOutputPlugin property tests', () => { expect(written).toContain('paths:') expect(written).not.toMatch(/^globs:/m) expect(written).toContain(content) - for (const g of globs) expect(written).toContain(`- "${g}"`) + for (const g of globs) expect(written).toMatch(new RegExp(`-\\s+['"]?${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?`)) // Accept quoted or unquoted formats }), {numRuns: 30}) }) @@ -154,7 +154,7 @@ describe('claudeCodeCLIOutputPlugin property tests', () => { const written = fs.readFileSync(filePath, 'utf8') expect(written).toContain('paths:') expect(written).toContain(content) - for (const g of globs) expect(written).toContain(`- "${g}"`) + for (const g of globs) expect(written).toMatch(new RegExp(`-\\s+['"]?${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?`)) // Accept quoted or unquoted formats }), {numRuns: 30}) }) }) diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts index cfd70e05..eda325c0 100644 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts @@ -361,15 +361,15 @@ describe('claudeCodeCLIOutputPlugin', () => { it('should output paths as YAML array items', () => { const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts', '**/*.tsx'], content: '# Body'}) const content = plugin.testBuildRuleContent(rule) - expect(content).toContain('- "**/*.ts"') - expect(content).toContain('- "**/*.tsx"') + expect(content).toMatch(/-\s+['"]?\*\*\/\*\.ts['"]?/) // Accept quoted or unquoted formats + expect(content).toMatch(/-\s+['"]?\*\*\/\*\.tsx['"]?/) }) - it('should double-quote paths that do not start with *', () => { + it('should include paths in YAML array', () => { const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['src/components/*.tsx', 'lib/utils.ts'], content: '# Body'}) const content = plugin.testBuildRuleContent(rule) - expect(content).toContain('- "src/components/*.tsx"') - expect(content).toContain('- "lib/utils.ts"') + expect(content).toMatch(/-\s+['"]?src\/components\/\*\.tsx['"]?/) // Accept quoted or unquoted formats + expect(content).toMatch(/-\s+['"]?lib\/utils\.ts['"]?/) }) it('should preserve rule body after frontmatter', () => { diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts index 52b6de9b..050b9145 100644 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts @@ -2,8 +2,7 @@ import type {OutputPluginContext, OutputWriteContext, RulePrompt, WriteResults} import type {RelativePath} from '@truenine/plugin-shared/types' import * as path from 'node:path' import {buildMarkdownWithFrontMatter, doubleQuoted} from '@truenine/md-compiler/markdown' -import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterRulesByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {applySubSeriesGlobPrefix, BaseCLIOutputPlugin, filterRulesByProjectConfig} from '@truenine/plugin-output-shared' const PROJECT_MEMORY_FILE = 'CLAUDE.md' const GLOBAL_CONFIG_DIR = '.claude' diff --git a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts index e224b5c2..0a7209c3 100644 --- a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts +++ b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts @@ -2,7 +2,7 @@ import type {OutputPluginContext} from '@truenine/plugin-shared' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' +import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {CursorOutputPlugin} from './CursorOutputPlugin' diff --git a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts index e9f5805e..4c65e6ff 100644 --- a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts +++ b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts @@ -13,8 +13,7 @@ import {Buffer} from 'node:buffer' import * as fs from 'node:fs' import * as path from 'node:path' import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {AbstractOutputPlugin, applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' const GLOBAL_CONFIG_DIR = '.cursor' diff --git a/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.ts b/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.ts index a8df3611..de19e3d4 100644 --- a/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.ts +++ b/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.ts @@ -7,8 +7,7 @@ import type { import type {RelativePath} from '@truenine/plugin-shared/types' import * as fs from 'node:fs' import * as path from 'node:path' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {findAllGitRepos, findGitModuleInfoDirs, resolveGitInfoDir} from '@truenine/plugin-output-shared/utils' +import {AbstractOutputPlugin, findAllGitRepos, findGitModuleInfoDirs, resolveGitInfoDir} from '@truenine/plugin-output-shared' import {FilePathKind} from '@truenine/plugin-shared' export class GitExcludeOutputPlugin extends AbstractOutputPlugin { diff --git a/cli/src/plugins/plugin-input-shared/index.ts b/cli/src/plugins/plugin-input-shared/index.ts index 0941c3dd..a1805ac1 100644 --- a/cli/src/plugins/plugin-input-shared/index.ts +++ b/cli/src/plugins/plugin-input-shared/index.ts @@ -13,3 +13,8 @@ export { export type { FileInputPluginOptions } from './BaseFileInputPlugin' +export { + GlobalScopeCollector, + ScopePriority, + ScopeRegistry +} from './scope' diff --git a/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts b/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts index ac48d070..3d66aede 100644 --- a/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts +++ b/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts @@ -13,8 +13,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import {getPlatformFixedDir} from '@truenine/desk-paths' import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {AbstractOutputPlugin, filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' /** diff --git a/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts b/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts index 99b4c210..e9893253 100644 --- a/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts @@ -9,8 +9,7 @@ import type { import type {RelativePath} from '@truenine/plugin-shared/types' import * as path from 'node:path' import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {AbstractOutputPlugin, filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' import {PLUGIN_NAMES} from '@truenine/plugin-shared' const PROJECT_MEMORY_FILE = 'AGENTS.md' diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts index 135af5cf..3ae4e071 100644 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts @@ -2,7 +2,7 @@ import type {OutputPluginContext} from '@truenine/plugin-shared' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' +import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {OpencodeCLIOutputPlugin} from './OpencodeCLIOutputPlugin' diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.property.test.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.property.test.ts index 0ca383b5..ce30d4e4 100644 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.property.test.ts +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.property.test.ts @@ -116,7 +116,7 @@ describe('opencodeCLIOutputPlugin property tests', () => { await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { const rule = createMockRulePrompt({series, ruleName, globs, content}) const output = plugin.testBuildRuleContent(rule) - for (const g of globs) expect(output).toMatch(new RegExp(`- "${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}"|- ${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)) + for (const g of globs) expect(output).toMatch(new RegExp(`-\\s+['"]?${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?`)) // Accept quoted or unquoted formats }), {numRuns: 100}) }) }) diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts index 068f2dd0..21b978dc 100644 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts @@ -642,8 +642,8 @@ describe('opencodeCLIOutputPlugin', () => { it('should output globs as YAML array items', () => { const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts', '**/*.tsx'], content: '# Body'}) const content = plugin.testBuildRuleContent(rule) - expect(content).toContain('- "**/*.ts"') - expect(content).toContain('- "**/*.tsx"') + expect(content).toMatch(/-\s+['"]?\*\*\/\*\.ts['"]?/) // Accept quoted or unquoted formats + expect(content).toMatch(/-\s+['"]?\*\*\/\*\.tsx['"]?/) }) it('should preserve rule body after frontmatter', () => { diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts index ca9fac85..4a70d190 100644 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts @@ -2,8 +2,7 @@ import type {FastCommandPrompt, McpServerConfig, OutputPluginContext, OutputWrit import type {RelativePath} from '@truenine/plugin-shared/types' import * as fs from 'node:fs' import * as path from 'node:path' -import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {applySubSeriesGlobPrefix, BaseCLIOutputPlugin, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' const GLOBAL_MEMORY_FILE = 'AGENTS.md' diff --git a/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts b/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts index 3c58599a..cfe8db8e 100644 --- a/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts @@ -14,7 +14,7 @@ import type {AbstractOutputPluginOptions} from './AbstractOutputPlugin' import * as path from 'node:path' import {writeFileSync as deskWriteFileSync} from '@truenine/desk-paths' import {mdxToMd} from '@truenine/md-compiler' -import {GlobalScopeCollector} from '@truenine/plugin-input-shared/scope' +import {GlobalScopeCollector} from '@truenine/plugin-input-shared' import {AbstractOutputPlugin} from './AbstractOutputPlugin' import {filterCommandsByProjectConfig, filterSkillsByProjectConfig, filterSubAgentsByProjectConfig} from './utils' diff --git a/cli/src/plugins/plugin-output-shared/index.ts b/cli/src/plugins/plugin-output-shared/index.ts index 00e937bd..a93e9de3 100644 --- a/cli/src/plugins/plugin-output-shared/index.ts +++ b/cli/src/plugins/plugin-output-shared/index.ts @@ -12,3 +12,15 @@ export { export type { BaseCLIOutputPluginOptions } from './BaseCLIOutputPlugin' +export { + applySubSeriesGlobPrefix, + filterCommandsByProjectConfig, + filterRulesByProjectConfig, + filterSkillsByProjectConfig, + findAllGitRepos, + findGitModuleInfoDirs, + matchesSeries, + resolveEffectiveIncludeSeries, + resolveGitInfoDir, + resolveSubSeries +} from './utils' diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts index cef861dc..569b278c 100644 --- a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts +++ b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts @@ -2,7 +2,7 @@ import type {OutputWriteContext} from '@truenine/plugin-shared' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import {createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' +import {createMockProject, createMockRulePrompt} from '@truenine/plugin-shared' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {QoderIDEPluginOutputPlugin} from './QoderIDEPluginOutputPlugin' diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts index eb3ef122..e7183c22 100644 --- a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts +++ b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts @@ -12,8 +12,7 @@ import type {RelativePath} from '@truenine/plugin-shared/types' import {Buffer} from 'node:buffer' import * as path from 'node:path' import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {AbstractOutputPlugin, applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' const QODER_CONFIG_DIR = '.qoder' const RULES_SUBDIR = 'rules' diff --git a/cli/src/plugins/plugin-shared/index.ts b/cli/src/plugins/plugin-shared/index.ts index 3b272060..741ae66c 100644 --- a/cli/src/plugins/plugin-shared/index.ts +++ b/cli/src/plugins/plugin-shared/index.ts @@ -20,4 +20,9 @@ export { export type { PluginName } from './PluginNames' +export { + collectFileNames, + createMockProject, + createMockRulePrompt +} from './testing' export * from './types' diff --git a/cli/src/plugins/plugin-shared/types/PluginTypes.ts b/cli/src/plugins/plugin-shared/types/PluginTypes.ts index 0e2cf619..cdc9cf06 100644 --- a/cli/src/plugins/plugin-shared/types/PluginTypes.ts +++ b/cli/src/plugins/plugin-shared/types/PluginTypes.ts @@ -8,6 +8,8 @@ import type { Project } from './InputTypes' +export type FastGlobType = typeof import('fast-glob') + /** * Opaque type for ScopeRegistry. * Concrete implementation lives in plugin-input-shared. @@ -27,7 +29,7 @@ export interface PluginContext { logger: ILogger fs: typeof import('node:fs') path: typeof import('node:path') - glob: typeof import('fast-glob') + glob: FastGlobType } export interface InputPluginContext extends PluginContext { @@ -153,7 +155,7 @@ export interface InputEffectContext { /** Path module */ readonly path: typeof import('node:path') /** Glob module for file matching */ - readonly glob: typeof import('fast-glob') + readonly glob: FastGlobType /** Child process spawn function */ readonly spawn: typeof import('node:child_process').spawn /** User configuration options */ diff --git a/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts index 971eb8c3..4632762f 100644 --- a/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts +++ b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts @@ -9,8 +9,7 @@ import type { } from '@truenine/plugin-shared' import type {RelativePath} from '@truenine/plugin-shared/types' import * as path from 'node:path' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {filterCommandsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {AbstractOutputPlugin, filterCommandsByProjectConfig} from '@truenine/plugin-output-shared' const GLOBAL_MEMORY_FILE = 'GLOBAL.md' const GLOBAL_CONFIG_DIR = '.trae' diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts index 9344187b..18355b09 100644 --- a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts +++ b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts @@ -2,7 +2,7 @@ import type {OutputPluginContext} from '@truenine/plugin-shared' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' +import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts index 95d82f07..0e140e9f 100644 --- a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts +++ b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts @@ -12,8 +12,7 @@ import {Buffer} from 'node:buffer' import * as fs from 'node:fs' import * as path from 'node:path' import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' +import {AbstractOutputPlugin, applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' const CODEIUM_WINDSURF_DIR = '.codeium/windsurf' diff --git a/cli/src/utils/ruleFilter.ts b/cli/src/utils/ruleFilter.ts index 43cfa4aa..27cfac91 100644 --- a/cli/src/utils/ruleFilter.ts +++ b/cli/src/utils/ruleFilter.ts @@ -1,5 +1,5 @@ import type {ProjectConfig, RulePrompt} from '@truenine/plugin-shared' -import {matchesSeries, resolveEffectiveIncludeSeries, resolveSubSeries} from '@truenine/plugin-output-shared/utils' +import {matchesSeries, resolveEffectiveIncludeSeries, resolveSubSeries} from '@truenine/plugin-output-shared' function normalizeSubdirPath(subdir: string): string { let normalized = subdir.replaceAll(/\.\/+/g, '') diff --git a/cli/tsconfig.eslint.json b/cli/tsconfig.eslint.json index 56de5015..585b38ee 100644 --- a/cli/tsconfig.eslint.json +++ b/cli/tsconfig.eslint.json @@ -11,7 +11,9 @@ "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", - "tsdown.config.ts" + "tsdown.config.ts", + "vite.config.ts", + "vitest.config.ts" ], "exclude": [ "../node_modules", diff --git a/cli/tsconfig.json b/cli/tsconfig.json index dfa88832..fc403126 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -54,7 +54,6 @@ "@truenine/plugin-input-workspace": ["./src/plugins/plugin-input-workspace/index.ts"], "@truenine/plugin-jetbrains-ai-codex": ["./src/plugins/plugin-jetbrains-ai-codex/index.ts"], "@truenine/plugin-jetbrains-codestyle": ["./src/plugins/plugin-jetbrains-codestyle/index.ts"], - "@truenine/plugin-kiro-ide": ["./src/plugins/plugin-kiro-ide/index.ts"], "@truenine/plugin-openai-codex-cli": ["./src/plugins/plugin-openai-codex-cli/index.ts"], "@truenine/plugin-opencode-cli": ["./src/plugins/plugin-opencode-cli/index.ts"], "@truenine/plugin-qoder-ide": ["./src/plugins/plugin-qoder-ide/index.ts"], @@ -62,7 +61,8 @@ "@truenine/plugin-trae-ide": ["./src/plugins/plugin-trae-ide/index.ts"], "@truenine/plugin-vscode": ["./src/plugins/plugin-vscode/index.ts"], "@truenine/plugin-warp-ide": ["./src/plugins/plugin-warp-ide/index.ts"], - "@truenine/plugin-windsurf": ["./src/plugins/plugin-windsurf/index.ts"] + "@truenine/plugin-windsurf": ["./src/plugins/plugin-windsurf/index.ts"], + "@truenine/config": ["./src/config/index.ts"] }, "resolveJsonModule": true, "allowImportingTsExtensions": true, @@ -105,7 +105,9 @@ "src/**/*", "env.d.ts", "eslint.config.ts", - "tsdown.config.ts" + "tsdown.config.ts", + "vite.config.ts", + "vitest.config.ts" ], "exclude": [ "../node_modules", diff --git a/cli/tsdown.config.ts b/cli/tsdown.config.ts index 9f55cd31..69b9cd37 100644 --- a/cli/tsdown.config.ts +++ b/cli/tsdown.config.ts @@ -40,7 +40,6 @@ const pluginAliases: Record = { '@truenine/plugin-input-workspace': resolve('src/plugins/plugin-input-workspace/index.ts'), '@truenine/plugin-jetbrains-ai-codex': resolve('src/plugins/plugin-jetbrains-ai-codex/index.ts'), '@truenine/plugin-jetbrains-codestyle': resolve('src/plugins/plugin-jetbrains-codestyle/index.ts'), - '@truenine/plugin-kiro-ide': resolve('src/plugins/plugin-kiro-ide/index.ts'), '@truenine/plugin-openai-codex-cli': resolve('src/plugins/plugin-openai-codex-cli/index.ts'), '@truenine/plugin-opencode-cli': resolve('src/plugins/plugin-opencode-cli/index.ts'), '@truenine/plugin-qoder-ide': resolve('src/plugins/plugin-qoder-ide/index.ts'), @@ -48,7 +47,8 @@ const pluginAliases: Record = { '@truenine/plugin-trae-ide': resolve('src/plugins/plugin-trae-ide/index.ts'), '@truenine/plugin-vscode': resolve('src/plugins/plugin-vscode/index.ts'), '@truenine/plugin-warp-ide': resolve('src/plugins/plugin-warp-ide/index.ts'), - '@truenine/plugin-windsurf': resolve('src/plugins/plugin-windsurf/index.ts') + '@truenine/plugin-windsurf': resolve('src/plugins/plugin-windsurf/index.ts'), + '@truenine/config': resolve('src/config/index.ts') } const noExternalDeps = [ diff --git a/cli/vite.config.ts b/cli/vite.config.ts index afe89a5b..891d70bf 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -1,4 +1,5 @@ import {readFileSync} from 'node:fs' +import {resolve} from 'node:path' import {fileURLToPath, URL} from 'node:url' import {bundles} from '@truenine/init-bundle' import {defineConfig} from 'vite' @@ -6,10 +7,61 @@ import {defineConfig} from 'vite' const pkg = JSON.parse(readFileSync('./package.json', 'utf8')) as {version: string, name: string} const kiroGlobalPowersRegistry = bundles['public/kiro_global_powers_registry.json']?.content ?? '{"version":"1.0.0","powers":{},"repoSources":{}}' +const pluginAliases: Record = { + '@truenine/desk-paths': resolve('src/plugins/desk-paths/index.ts'), + '@truenine/plugin-shared': resolve('src/plugins/plugin-shared/index.ts'), + '@truenine/plugin-shared/types': resolve('src/plugins/plugin-shared/types/index.ts'), + '@truenine/plugin-shared/testing': resolve('src/plugins/plugin-shared/testing/index.ts'), + '@truenine/plugin-output-shared': resolve('src/plugins/plugin-output-shared/index.ts'), + '@truenine/plugin-output-shared/utils': resolve('src/plugins/plugin-output-shared/utils/index.ts'), + '@truenine/plugin-output-shared/registry': resolve('src/plugins/plugin-output-shared/registry/index.ts'), + '@truenine/plugin-input-shared': resolve('src/plugins/plugin-input-shared/index.ts'), + '@truenine/plugin-input-shared/scope': resolve('src/plugins/plugin-input-shared/scope/index.ts'), + '@truenine/plugin-agentskills-compact': resolve('src/plugins/plugin-agentskills-compact/index.ts'), + '@truenine/plugin-agentsmd': resolve('src/plugins/plugin-agentsmd/index.ts'), + '@truenine/plugin-antigravity': resolve('src/plugins/plugin-antigravity/index.ts'), + '@truenine/plugin-claude-code-cli': resolve('src/plugins/plugin-claude-code-cli/index.ts'), + '@truenine/plugin-cursor': resolve('src/plugins/plugin-cursor/index.ts'), + '@truenine/plugin-droid-cli': resolve('src/plugins/plugin-droid-cli/index.ts'), + '@truenine/plugin-editorconfig': resolve('src/plugins/plugin-editorconfig/index.ts'), + '@truenine/plugin-gemini-cli': resolve('src/plugins/plugin-gemini-cli/index.ts'), + '@truenine/plugin-git-exclude': resolve('src/plugins/plugin-git-exclude/index.ts'), + '@truenine/plugin-input-agentskills': resolve('src/plugins/plugin-input-agentskills/index.ts'), + '@truenine/plugin-input-editorconfig': resolve('src/plugins/plugin-input-editorconfig/index.ts'), + '@truenine/plugin-input-fast-command': resolve('src/plugins/plugin-input-fast-command/index.ts'), + '@truenine/plugin-input-git-exclude': resolve('src/plugins/plugin-input-git-exclude/index.ts'), + '@truenine/plugin-input-gitignore': resolve('src/plugins/plugin-input-gitignore/index.ts'), + '@truenine/plugin-input-global-memory': resolve('src/plugins/plugin-input-global-memory/index.ts'), + '@truenine/plugin-input-jetbrains-config': resolve('src/plugins/plugin-input-jetbrains-config/index.ts'), + '@truenine/plugin-input-md-cleanup-effect': resolve('src/plugins/plugin-input-md-cleanup-effect/index.ts'), + '@truenine/plugin-input-orphan-cleanup-effect': resolve('src/plugins/plugin-input-orphan-cleanup-effect/index.ts'), + '@truenine/plugin-input-project-prompt': resolve('src/plugins/plugin-input-project-prompt/index.ts'), + '@truenine/plugin-input-readme': resolve('src/plugins/plugin-input-readme/index.ts'), + '@truenine/plugin-input-rule': resolve('src/plugins/plugin-input-rule/index.ts'), + '@truenine/plugin-input-shadow-project': resolve('src/plugins/plugin-input-shadow-project/index.ts'), + '@truenine/plugin-input-shared-ignore': resolve('src/plugins/plugin-input-shared-ignore/index.ts'), + '@truenine/plugin-input-skill-sync-effect': resolve('src/plugins/plugin-input-skill-sync-effect/index.ts'), + '@truenine/plugin-input-subagent': resolve('src/plugins/plugin-input-subagent/index.ts'), + '@truenine/plugin-input-vscode-config': resolve('src/plugins/plugin-input-vscode-config/index.ts'), + '@truenine/plugin-input-workspace': resolve('src/plugins/plugin-input-workspace/index.ts'), + '@truenine/plugin-jetbrains-ai-codex': resolve('src/plugins/plugin-jetbrains-ai-codex/index.ts'), + '@truenine/plugin-jetbrains-codestyle': resolve('src/plugins/plugin-jetbrains-codestyle/index.ts'), + '@truenine/plugin-openai-codex-cli': resolve('src/plugins/plugin-openai-codex-cli/index.ts'), + '@truenine/plugin-opencode-cli': resolve('src/plugins/plugin-opencode-cli/index.ts'), + '@truenine/plugin-qoder-ide': resolve('src/plugins/plugin-qoder-ide/index.ts'), + '@truenine/plugin-readme': resolve('src/plugins/plugin-readme/index.ts'), + '@truenine/plugin-trae-ide': resolve('src/plugins/plugin-trae-ide/index.ts'), + '@truenine/plugin-vscode': resolve('src/plugins/plugin-vscode/index.ts'), + '@truenine/plugin-warp-ide': resolve('src/plugins/plugin-warp-ide/index.ts'), + '@truenine/plugin-windsurf': resolve('src/plugins/plugin-windsurf/index.ts'), + '@truenine/config': resolve('src/config/index.ts') +} + export default defineConfig({ resolve: { alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) + '@': fileURLToPath(new URL('./src', import.meta.url)), + ...pluginAliases } }, define: { diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index bd344383..482bee96 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -15,8 +15,8 @@ export default mergeConfig( enabled: true, tsconfig: './tsconfig.test.json' }, - testTimeout: 30000, // Property-based tests run more iterations - onConsoleLog: () => false, // Minimal output: suppress console logs, show summary only + testTimeout: 30000, + onConsoleLog: () => false, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], From 7ae706b95524743f955ed936c79671a61225c952 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Sun, 1 Mar 2026 12:49:17 +0800 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=E5=B0=86=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=90=8D=E7=A7=B0=E4=BB=8E'tnmsc-shadow'?= =?UTF-8?q?=E6=94=B9=E4=B8=BA'aindex'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新了默认配置和测试用例中的项目名称,统一使用新名称'aindex'。同时修复了文档中的格式问题。 --- CODE_OF_CONDUCT.md | 2 +- README.md | 32 +++++++++---------- SECURITY.md | 8 ++--- cli/src/config.ts | 2 +- .../GitExcludeInputPlugin.test.ts | 2 +- .../GitIgnoreInputPlugin.test.ts | 2 +- .../AbstractInputPlugin.test.ts | 4 +-- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index d358fd51..befffbb4 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -73,4 +73,4 @@ Maintainers are not obligated to: This project is licensed under [AGPL-3.0](LICENSE). Commercial use violating the licence will be subject to legal action. -Enforcement of this code of conduct is at the maintainers' sole discretion; final interpretation rests with [@TrueNine](https://github.com/TrueNine). +Enforcement of this code of conduct is at the maintainers' sole discretion; final interpretation rests with [@TrueNine](https://github.com/TrueNine). \ No newline at end of file diff --git a/README.md b/README.md index 3b88b1b1..a26ac7c0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Rats 🐀 are like this: even our own brains, even our memories, are things we haul around while running through this fucked-up world!!! -I am a rat. No resources will ever be proactively provided to me.\ +I am a rat. No resources will ever be proactively provided to me. So as a rat, I eat whatever I can reach: maggots in the sewer, leftovers in the slop bucket, and in extreme cases even my own kind—this is the survival mode in a world where resource allocation is brutally unfair. `memory-sync` is the same kind of **tool-rat**: @@ -11,7 +11,7 @@ So as a rat, I eat whatever I can reach: maggots in the sewer, leftovers in the - Does not rely on privileged interfaces of any single IDE / CLI - Treats every readable config, prompt, and memory file as "edible matter" to be carried, dismantled, and recombined -In this ecosystem, giants monopolise the resources, and developers are thrown into the corner like rats.\ +In this ecosystem, giants monopolise the resources, and developers are thrown into the corner like rats. `memory-sync` accepts this cruel reality, does not fantasise about fairness, and focuses on one thing only: **to chew up every fragment of resource you already have, and convert it into portable "memory" that can flow between any AI tool.** ![rat](/.attachments/rat.svg) @@ -76,38 +76,38 @@ To use `memory-sync` you need: --- -- You are writing code in a forgotten sewer.\ +- You are writing code in a forgotten sewer. No one will proactively feed you, not even a tiny free quota, not even a half-decent document. -- As a rat, you can barely get your hands on anything good:\ +- As a rat, you can barely get your hands on anything good: scurrying between free tiers, trial credits, education discounts, and random third-party scripts. -- What can you do?\ +- What can you do? Keep darting between IDEs, CLIs, browser extensions, and cloud Agents, copying and pasting the same memory a hundred times. -- You leech API offers from vendors day after day:\ +- You leech API offers from vendors day after day: today one platform runs a discount so you top up a little; tomorrow another launches a promo so you rush to scrape it. -- Once they have harvested the telemetry, user profiles, and usage patterns they want,\ +- Once they have harvested the telemetry, user profiles, and usage patterns they want, they can kick you—this stinking rat—away at any moment: price hikes, rate limits, account bans, and you have no channel to complain. -If you are barely surviving in this environment, `memory-sync` is built for you:\ +If you are barely surviving in this environment, `memory-sync` is built for you: carry fewer bricks, copy prompts fewer times—at least on the "memory" front, you are no longer completely on the passive receiving end. ## Who is NOT welcome -- Your income is already fucking high.\ +- Your income is already fucking high. Stable salary, project revenue share, budget to sign official APIs yearly. -- And yet you still come down here,\ +- And yet you still come down here, competing with us filthy sewer rats for the scraps in the slop bucket. -- If you can afford APIs and enterprise plans, go pay for them.\ +- If you can afford APIs and enterprise plans, go pay for them. Do things that actually create value—pay properly, give proper feedback, nudge the ecosystem slightly in the right direction. -- Instead of coming back down\ +- Instead of coming back down to strip away the tiny gap left for marginalised developers, squeezing out the last crumbs with us rats. -- You are a freeloader.\ +- You are a freeloader. Everything must be pre-chewed and spoon-fed; you won't even touch a terminal. -- You love the grind culture.\ +- You love the grind culture. Treating "hustle" as virtue, "996" as glory, stepping on peers as a promotion strategy. -- You leave no room for others.\ +- You leave no room for others. Not about whether you share—it's about actively stomping on people, competing maliciously, sustaining your position by suppressing peers, using others' survival space as your stepping stone. -In other words:\ +In other words: **this is not a tool for optimising capital costs, but a small counterattack prepared for the "rats with no choice" in a world of extreme resource inequality.** ## Created by diff --git a/SECURITY.md b/SECURITY.md index 99befb66..86fff327 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,9 +5,9 @@ Only the latest release receives security fixes. No backport patches for older versions. | Version | Supported | -|---------|-----------| -| Latest | ✅ | -| Older | ❌ | +| ------- | --------- | +| Latest | ✅ | +| Older | ❌ | ## Reporting a Vulnerability @@ -58,4 +58,4 @@ The following are **out of scope**: ## License -This project is licensed under [AGPL-3.0](LICENSE). Unauthorised commercial use in violation of the licence will be pursued legally. +This project is licensed under [AGPL-3.0](LICENSE). Unauthorised commercial use in violation of the licence will be pursued legally. \ No newline at end of file diff --git a/cli/src/config.ts b/cli/src/config.ts index 41bb023a..e226665c 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -18,7 +18,7 @@ export interface PipelineConfig { } const DEFAULT_SHADOW_SOURCE_PROJECT: Required = { - name: 'tnmsc-shadow', + name: 'aindex', skill: {src: 'src/skills', dist: 'dist/skills'}, fastCommand: {src: 'src/commands', dist: 'dist/commands'}, subAgent: {src: 'src/agents', dist: 'dist/agents'}, diff --git a/cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.test.ts b/cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.test.ts index 26d24faa..a9199200 100644 --- a/cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.test.ts +++ b/cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.test.ts @@ -9,7 +9,7 @@ vi.mock('node:fs') const BASE_OPTIONS = { workspaceDir: '/workspace', shadowSourceProject: { - name: 'tnmsc-shadow', + name: 'aindex', skill: {src: 'src/skills', dist: 'dist/skills'}, fastCommand: {src: 'src/commands', dist: 'dist/commands'}, subAgent: {src: 'src/agents', dist: 'dist/agents'}, diff --git a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts index 26304fcb..f70fc2b4 100644 --- a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts +++ b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts @@ -10,7 +10,7 @@ vi.mock('node:fs') const BASE_OPTIONS = { workspaceDir: '/workspace', shadowSourceProject: { - name: 'tnmsc-shadow', + name: 'aindex', skill: {src: 'src/skills', dist: 'dist/skills'}, fastCommand: {src: 'src/commands', dist: 'dist/commands'}, subAgent: {src: 'src/agents', dist: 'dist/agents'}, diff --git a/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts b/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts index b5ba177c..c9708abd 100644 --- a/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts +++ b/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts @@ -12,7 +12,7 @@ function createTestOptions(overrides: Partial = {}): Required { const {workspaceDir, shadowProjectDir} = plugin.exposeResolveBasePaths(options) expect(workspaceDir).toBe(path.normalize('/custom/workspace')) - expect(shadowProjectDir).toBe(path.normalize('/custom/workspace/tnmsc-shadow')) + expect(shadowProjectDir).toBe(path.normalize('/custom/workspace/aindex')) }) it('should construct shadow project dir from name', () => { From 79d06e9487f5148faef856b5626648c4e5a5b49e Mon Sep 17 00:00:00 2001 From: TrueNine Date: Sun, 1 Mar 2026 13:07:30 +0800 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=93=BE=E6=8E=A5=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除不再需要的配置链接功能,包括`ensureConfigLink`和`ensureShadowProjectConfigLink`函数,以及相关的测试代码 --- cli/src/ConfigLoader.test.ts | 136 +-------------------------- cli/src/ConfigLoader.ts | 73 -------------- cli/src/commands/InitCommand.test.ts | 132 +------------------------- cli/src/commands/InitCommand.ts | 15 --- cli/src/config.ts | 6 +- 5 files changed, 3 insertions(+), 359 deletions(-) diff --git a/cli/src/ConfigLoader.test.ts b/cli/src/ConfigLoader.test.ts index f72dc568..2363ec0a 100644 --- a/cli/src/ConfigLoader.test.ts +++ b/cli/src/ConfigLoader.test.ts @@ -2,15 +2,10 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {ConfigLoader, DEFAULT_CONFIG_FILE_NAME, DEFAULT_GLOBAL_CONFIG_DIR, ensureConfigLink, loadUserConfig} from './ConfigLoader' +import {ConfigLoader, DEFAULT_CONFIG_FILE_NAME, DEFAULT_GLOBAL_CONFIG_DIR, loadUserConfig} from './ConfigLoader' vi.mock('node:fs') // Mock fs module vi.mock('node:os') -vi.mock('@truenine/desk-paths', () => ({ - isSymlink: vi.fn(), - readSymlinkTarget: vi.fn(), - deletePathSync: vi.fn() -})) describe('configLoader', () => { const mockHomedir = '/home/testuser' @@ -333,132 +328,3 @@ describe('configLoader', () => { }) }) }) - -describe('ensureConfigLink', () => { - let deskPaths: typeof import('@truenine/desk-paths') - - const LOCAL = '/shadow/.tnmsc.json' - const GLOBAL = '/home/testuser/.aindex/.tnmsc.json' - - const logger = { - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - fatal: vi.fn() - } - - beforeEach(async () => { - deskPaths = await import('@truenine/desk-paths') - vi.mocked(os.homedir).mockReturnValue('/home/testuser') - vi.mocked(fs.existsSync).mockReturnValue(false) - vi.mocked(fs.symlinkSync).mockImplementation(() => void 0) - vi.mocked(fs.copyFileSync).mockImplementation(() => void 0) - vi.mocked(deskPaths.isSymlink).mockReturnValue(false) - vi.mocked(deskPaths.readSymlinkTarget).mockReturnValue(null) - vi.mocked(deskPaths.deletePathSync).mockImplementation(() => void 0) - }) - - afterEach(() => vi.clearAllMocks()) - - it('no-op when global config does not exist', () => { - vi.mocked(fs.existsSync).mockReturnValue(false) - - ensureConfigLink(LOCAL, GLOBAL, logger) - - expect(fs.symlinkSync).not.toHaveBeenCalled() - expect(fs.copyFileSync).not.toHaveBeenCalled() - }) - - it('creates symlink when local file does not exist', () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL) - vi.mocked(deskPaths.isSymlink).mockReturnValue(false) - - ensureConfigLink(LOCAL, GLOBAL, logger) - - expect(fs.symlinkSync).toHaveBeenCalledWith(GLOBAL, LOCAL, 'file') - }) - - it('no-op when local is a correct symlink pointing to global', () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL || p === LOCAL) - vi.mocked(deskPaths.isSymlink).mockReturnValue(true) - vi.mocked(deskPaths.readSymlinkTarget).mockReturnValue(GLOBAL) - - ensureConfigLink(LOCAL, GLOBAL, logger) - - expect(fs.symlinkSync).not.toHaveBeenCalled() - expect(deskPaths.deletePathSync).not.toHaveBeenCalled() - }) - - it('deletes stale symlink and recreates when target differs', () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL || p === LOCAL) - vi.mocked(deskPaths.isSymlink).mockReturnValue(true) - vi.mocked(deskPaths.readSymlinkTarget).mockReturnValue('/other/path/.tnmsc.json') - - ensureConfigLink(LOCAL, GLOBAL, logger) - - expect(deskPaths.deletePathSync).toHaveBeenCalledWith(LOCAL) - expect(fs.symlinkSync).toHaveBeenCalledWith(GLOBAL, LOCAL, 'file') - }) - - it('syncs regular file back to global when local content differs, then recreates symlink', () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL || p === LOCAL) - vi.mocked(deskPaths.isSymlink).mockReturnValue(false) - vi.mocked(fs.readFileSync).mockImplementation(p => { - if (p === LOCAL) return '{"local":true}' - return '{"global":true}' - }) - - ensureConfigLink(LOCAL, GLOBAL, logger) - - expect(fs.copyFileSync).toHaveBeenCalledWith(LOCAL, GLOBAL) - expect(deskPaths.deletePathSync).toHaveBeenCalledWith(LOCAL) - expect(fs.symlinkSync).toHaveBeenCalledWith(GLOBAL, LOCAL, 'file') - }) - - it('deletes regular file without sync-back when local content matches global', () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL || p === LOCAL) - vi.mocked(deskPaths.isSymlink).mockReturnValue(false) - vi.mocked(fs.readFileSync).mockReturnValue('{"same":true}') - - ensureConfigLink(LOCAL, GLOBAL, logger) - - expect(fs.copyFileSync).not.toHaveBeenCalledWith(LOCAL, GLOBAL) - expect(deskPaths.deletePathSync).toHaveBeenCalledWith(LOCAL) - expect(fs.symlinkSync).toHaveBeenCalledWith(GLOBAL, LOCAL, 'file') - }) - - it('falls back to copy when symlink fails', () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL) - vi.mocked(deskPaths.isSymlink).mockReturnValue(false) - vi.mocked(fs.symlinkSync).mockImplementation(() => { - throw new Error('EPERM: operation not permitted') - }) - - ensureConfigLink(LOCAL, GLOBAL, logger) - - expect(fs.copyFileSync).toHaveBeenCalledWith(GLOBAL, LOCAL) - expect(logger.warn).toHaveBeenCalledWith( - 'symlink unavailable, copied config (auto-sync disabled)', - expect.objectContaining({dest: LOCAL}) - ) - }) - - it('logs warn and does not throw when both symlink and copy fail', () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL) - vi.mocked(deskPaths.isSymlink).mockReturnValue(false) - vi.mocked(fs.symlinkSync).mockImplementation(() => { - throw new Error('EPERM') - }) - vi.mocked(fs.copyFileSync).mockImplementation(() => { - throw new Error('ENOENT') - }) - - expect(() => ensureConfigLink(LOCAL, GLOBAL, logger)).not.toThrow() - expect(logger.warn).toHaveBeenCalledWith( - 'failed to link or copy config', - expect.objectContaining({path: LOCAL, error: 'ENOENT'}) - ) - }) -}) diff --git a/cli/src/ConfigLoader.ts b/cli/src/ConfigLoader.ts index 01a991f3..d002b1db 100644 --- a/cli/src/ConfigLoader.ts +++ b/cli/src/ConfigLoader.ts @@ -1,10 +1,8 @@ import type {ConfigLoaderOptions, ConfigLoadResult, ILogger, ShadowSourceProjectConfig, UserConfigFile} from '@truenine/plugin-shared' -import {createHash} from 'node:crypto' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import process from 'node:process' -import {deletePathSync, isSymlink, readSymlinkTarget} from '@truenine/desk-paths' import {createLogger, DEFAULT_USER_CONFIG, ZUserConfigFile} from '@truenine/plugin-shared' /** @@ -224,77 +222,6 @@ export function loadUserConfig(cwd?: string): MergedConfigResult { return getConfigLoader().load(cwd) } -/** - * Ensure a local config file is linked (symlink preferred) to the global config. - * On every run: - * - If local is a correct symlink → no-op - * - If local is a stale symlink → delete and recreate - * - If local is a regular file newer than global → sync back to global, then recreate link - * - If local is a regular file older than global → delete and recreate link - * Falls back to a file copy with a warning when symlink creation fails. - */ -export function ensureConfigLink( - localConfigPath: string, - globalConfigPath: string, - logger: ILogger -): void { - if (!fs.existsSync(globalConfigPath)) return - - if (fs.existsSync(localConfigPath) || isSymlink(localConfigPath)) { - if (isSymlink(localConfigPath)) { - const target = readSymlinkTarget(localConfigPath) - if (target !== null && path.resolve(target) === path.resolve(globalConfigPath)) return // correct symlink, no-op - deletePathSync(localConfigPath) // stale symlink — delete and fall through - } else { - const localContent = fs.readFileSync(localConfigPath) - const globalContent = fs.readFileSync(globalConfigPath) - const localHash = createHash('sha256').update(localContent).digest('hex') - const globalHash = createHash('sha256').update(globalContent).digest('hex') - if (localHash !== globalHash) { - fs.copyFileSync(localConfigPath, globalConfigPath) // local differs: sync back to global - logger.debug('synced local config back to global', {src: localConfigPath, dest: globalConfigPath}) - } - deletePathSync(localConfigPath) - } - } - - try { - fs.symlinkSync(globalConfigPath, localConfigPath, 'file') - logger.debug('linked config', {link: localConfigPath, target: globalConfigPath}) - } - catch { - try { - fs.copyFileSync(globalConfigPath, localConfigPath) // fallback copy: auto-sync disabled, local edits preserved on next run via content-hash check - logger.warn('symlink unavailable, copied config (auto-sync disabled)', {dest: localConfigPath}) - } - catch (copyErr) { - logger.warn('failed to link or copy config', { - path: localConfigPath, - error: copyErr instanceof Error ? copyErr.message : String(copyErr) - }) - } - } -} - -/** - * Ensure the shadow source project directory has a .tnmsc.json symlink - * pointing to the global config. Creates a symlink where possible, falls - * back to a file copy when symlinks are unavailable (e.g. Windows without - * Developer Mode). On every run, syncs edits made inside the shadow back - * to the global config before relinking. - */ -export function ensureShadowProjectConfigLink(shadowProjectDir: string, logger: ILogger): void { - const resolved = shadowProjectDir.startsWith('~') - ? path.join(os.homedir(), shadowProjectDir.slice(1)) - : shadowProjectDir - - if (!fs.existsSync(resolved)) return - - const globalConfigPath = getGlobalConfigPath() - const configPath = path.join(resolved, DEFAULT_CONFIG_FILE_NAME) - ensureConfigLink(configPath, globalConfigPath, logger) -} - /** * Validate global config file strictly. * - If config doesn't exist: create default config, log warn, continue diff --git a/cli/src/commands/InitCommand.test.ts b/cli/src/commands/InitCommand.test.ts index 3237cb45..2dda0df1 100644 --- a/cli/src/commands/InitCommand.test.ts +++ b/cli/src/commands/InitCommand.test.ts @@ -1,18 +1,9 @@ import type {CommandContext, CommandResult} from './Command' -import * as fs from 'node:fs' import * as os from 'node:os' -import * as path from 'node:path' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {DEFAULT_CONFIG_FILE_NAME, DEFAULT_GLOBAL_CONFIG_DIR} from '@/ConfigLoader' import {InitCommand} from './InitCommand' -vi.mock('node:fs') vi.mock('node:os') -vi.mock('@truenine/desk-paths', () => ({ - isSymlink: vi.fn(() => false), - readSymlinkTarget: vi.fn(() => null), - deletePathSync: vi.fn() -})) vi.mock('@/ShadowSourceProject', () => ({ generateShadowSourceProject: vi.fn(() => ({ success: true, @@ -25,9 +16,6 @@ vi.mock('@/ShadowSourceProject', () => ({ })) const MOCK_HOME = '/home/testuser' -const MOCK_CWD = '/workspace/myproject' -const GLOBAL_CONFIG_PATH = path.join(MOCK_HOME, DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_CONFIG_FILE_NAME) -const CWD_CONFIG_PATH = path.join(MOCK_CWD, DEFAULT_CONFIG_FILE_NAME) function makeCtx(overrides: Partial = {}): CommandContext { const logger = { @@ -65,13 +53,7 @@ function makeCtx(overrides: Partial = {}): CommandContext { } describe('initCommand', () => { - beforeEach(() => { - vi.mocked(os.homedir).mockReturnValue(MOCK_HOME) - vi.spyOn(process, 'cwd').mockReturnValue(MOCK_CWD) - vi.mocked(fs.existsSync).mockReturnValue(false) - vi.mocked(fs.symlinkSync).mockImplementation(() => void 0) - vi.mocked(fs.copyFileSync).mockImplementation(() => void 0) - }) + beforeEach(() => vi.mocked(os.homedir).mockReturnValue(MOCK_HOME)) afterEach(() => vi.clearAllMocks()) @@ -95,116 +77,4 @@ describe('initCommand', () => { expect(result.message!.length).toBeGreaterThan(0) }) }) - - describe('linkCwdConfig — symlink happy path', () => { - it('creates a symlink at cwd/.tnmsc.json pointing to global config', async () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL_CONFIG_PATH) - - await new InitCommand().execute(makeCtx()) - - expect(fs.symlinkSync).toHaveBeenCalledWith(GLOBAL_CONFIG_PATH, CWD_CONFIG_PATH, 'file') - }) - - it('logs debug after successful symlink creation', async () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL_CONFIG_PATH) - const ctx = makeCtx() - - await new InitCommand().execute(ctx) - - expect(ctx.logger.debug).toHaveBeenCalledWith( - 'linked config', - expect.objectContaining({link: CWD_CONFIG_PATH, target: GLOBAL_CONFIG_PATH}) - ) - }) - }) - - describe('linkCwdConfig — symlink fallback to copy', () => { - it('falls back to copyFileSync when symlinkSync throws', async () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL_CONFIG_PATH) - vi.mocked(fs.symlinkSync).mockImplementation(() => { - throw new Error('EPERM: operation not permitted') - }) - - await new InitCommand().execute(makeCtx()) - - expect(fs.copyFileSync).toHaveBeenCalledWith(GLOBAL_CONFIG_PATH, CWD_CONFIG_PATH) - }) - - it('logs warn when falling back to copy', async () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL_CONFIG_PATH) - vi.mocked(fs.symlinkSync).mockImplementation(() => { - throw new Error('EPERM: operation not permitted') - }) - const ctx = makeCtx() - - await new InitCommand().execute(ctx) - - expect(ctx.logger.warn).toHaveBeenCalledWith( - 'symlink unavailable, copied config (auto-sync disabled)', - expect.objectContaining({dest: CWD_CONFIG_PATH}) - ) - }) - - it('logs warn when both symlink and copy fail', async () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL_CONFIG_PATH) - vi.mocked(fs.symlinkSync).mockImplementation(() => { - throw new Error('EPERM: operation not permitted') - }) - vi.mocked(fs.copyFileSync).mockImplementation(() => { - throw new Error('ENOENT: no such file or directory') - }) - const ctx = makeCtx() - - await new InitCommand().execute(ctx) - - expect(ctx.logger.warn).toHaveBeenCalledWith( - 'failed to link or copy config', - expect.objectContaining({path: CWD_CONFIG_PATH, error: 'ENOENT: no such file or directory'}) - ) - }) - - it('does not throw when both symlink and copy fail', async () => { - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL_CONFIG_PATH) - vi.mocked(fs.symlinkSync).mockImplementation(() => { - throw new Error('EPERM') - }) - vi.mocked(fs.copyFileSync).mockImplementation(() => { - throw new Error('ENOENT') - }) - - await expect(new InitCommand().execute(makeCtx())).resolves.not.toThrow() - }) - }) - - describe('linkCwdConfig — path construction', () => { - it('uses process.cwd() for cwd config path', async () => { - const customCwd = '/custom/project/dir' - vi.spyOn(process, 'cwd').mockReturnValue(customCwd) - const expectedCwdConfig = path.join(customCwd, DEFAULT_CONFIG_FILE_NAME) - vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL_CONFIG_PATH) - - await new InitCommand().execute(makeCtx()) - - expect(fs.symlinkSync).toHaveBeenCalledWith( - expect.any(String), - expectedCwdConfig, - 'file' - ) - }) - - it('uses os.homedir() for global config path', async () => { - const customHome = '/custom/home' - vi.mocked(os.homedir).mockReturnValue(customHome) - const expectedGlobal = path.join(customHome, DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_CONFIG_FILE_NAME) - vi.mocked(fs.existsSync).mockImplementation(p => p === expectedGlobal) - - await new InitCommand().execute(makeCtx()) - - expect(fs.symlinkSync).toHaveBeenCalledWith( - expectedGlobal, - expect.any(String), - 'file' - ) - }) - }) }) diff --git a/cli/src/commands/InitCommand.ts b/cli/src/commands/InitCommand.ts index aca2695e..ed34d196 100644 --- a/cli/src/commands/InitCommand.ts +++ b/cli/src/commands/InitCommand.ts @@ -1,8 +1,6 @@ import type {Command, CommandContext, CommandResult} from './Command' import * as os from 'node:os' import * as path from 'node:path' -import process from 'node:process' -import {DEFAULT_CONFIG_FILE_NAME, ensureConfigLink, getGlobalConfigPath} from '@/ConfigLoader' import {generateShadowSourceProject} from '@/ShadowSourceProject' /** @@ -14,17 +12,6 @@ function resolveWorkspacePath(workspaceDir: string): string { return path.normalize(resolved) } -/** - * Link cwd config to global config via symlink. - * Falls back to a file copy with a warning if symlink creation fails (e.g. Windows without Developer Mode). - * On every run, syncs edits made in cwd back to the global config before relinking. - */ -function linkCwdConfig(logger: CommandContext['logger']): void { - const globalConfigPath = getGlobalConfigPath() - const cwdConfigPath = path.join(process.cwd(), DEFAULT_CONFIG_FILE_NAME) - ensureConfigLink(cwdConfigPath, globalConfigPath, logger) -} - /** * Init command - initializes shadow source project directory structure */ @@ -42,8 +29,6 @@ export class InitCommand implements Command { const result = generateShadowSourceProject(shadowSourceProjectDir, {logger}) // Generate shadow source project structure - linkCwdConfig(logger) // Link cwd config to global config - const message = result.createdDirs.length === 0 && result.createdFiles.length === 0 ? `All ${result.existedDirs.length} directories and ${result.existedFiles.length} files already exist` : `Created ${result.createdDirs.length} directories and ${result.createdFiles.length} files (${result.existedDirs.length} dirs, ${result.existedFiles.length} files already existed)` diff --git a/cli/src/config.ts b/cli/src/config.ts index e226665c..9fdb0b27 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -4,7 +4,7 @@ import * as path from 'node:path' import process from 'node:process' import {createLogger, DEFAULT_USER_CONFIG, PluginKind} from '@truenine/plugin-shared' import glob from 'fast-glob' -import {ensureShadowProjectConfigLink, loadUserConfig, validateAndEnsureGlobalConfig} from './ConfigLoader' +import {loadUserConfig, validateAndEnsureGlobalConfig} from './ConfigLoader' import {PluginPipeline} from './PluginPipeline' import {checkVersionControl} from './ShadowSourceProject' @@ -207,10 +207,6 @@ export async function defineConfig(options: PluginOptions | DefineConfigOptions }) } - if (mergedOptions.workspaceDir != null) { // Auto-link .tnmsc.json into shadow source project dir (skip if workspaceDir unavailable, e.g. CI without napi binary) - ensureShadowProjectConfigLink(path.join(mergedOptions.workspaceDir, mergedOptions.shadowSourceProject.name), logger) - } - const baseCtx: Omit = { // Base context without dependencyContext, globalScope, scopeRegistry (will be provided by pipeline) logger, userConfigOptions: mergedOptions, From ad56d3c6989913ff7af97c7a9bb7f00fa79f5b1b Mon Sep 17 00:00:00 2001 From: TrueNine Date: Sun, 1 Mar 2026 13:35:31 +0800 Subject: [PATCH 06/10] =?UTF-8?q?refactor(plugin-agentskills-compact):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=8A=80=E8=83=BD=E8=BE=93=E5=87=BA=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=B8=BA=E7=9B=B4=E6=8E=A5=E5=86=99=E5=85=A5=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将全局技能目录结构改为直接写入项目目录下的 .agents/skills/ 路径 移除全局技能目录和符号链接逻辑,改为直接写入文件 同时保留对旧版 .skills/ 目录的清理支持 --- .../GenericSkillsOutputPlugin.test.ts | 175 +++++++------- .../GenericSkillsOutputPlugin.ts | 228 +++++++----------- 2 files changed, 172 insertions(+), 231 deletions(-) diff --git a/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts index f09ed44c..44115776 100644 --- a/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts +++ b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts @@ -192,7 +192,7 @@ describe('genericSkillsOutputPlugin', () => { }) describe('registerProjectOutputDirs', () => { - it('should register .skills directory for each project', async () => { + it('should register both .agents/skills and legacy .skills directories for each project', async () => { const ctx = createMockOutputPluginContext({ workspace: { directory: createMockRelativePath('.', mockWorkspaceDir), @@ -206,9 +206,11 @@ describe('genericSkillsOutputPlugin', () => { const results = await plugin.registerProjectOutputDirs(ctx) - expect(results).toHaveLength(2) - expect(results[0]?.path).toBe(path.join('project1', '.skills')) - expect(results[1]?.path).toBe(path.join('project2', '.skills')) + expect(results).toHaveLength(4) // Each project should register 2 directories: .agents/skills and .skills + expect(results[0]?.path).toBe(path.join('project1', '.agents', 'skills')) + expect(results[1]?.path).toBe(path.join('project1', '.skills')) + expect(results[2]?.path).toBe(path.join('project2', '.agents', 'skills')) + expect(results[3]?.path).toBe(path.join('project2', '.skills')) }) it('should return empty array when no skills exist', async () => { @@ -225,7 +227,7 @@ describe('genericSkillsOutputPlugin', () => { }) describe('registerGlobalOutputDirs', () => { - it('should register ~/.skills/ directory when skills exist', async () => { + it('should return empty array (no global output dirs)', async () => { const ctx = createMockOutputPluginContext({ workspace: { directory: createMockRelativePath('.', mockWorkspaceDir), @@ -236,28 +238,28 @@ describe('genericSkillsOutputPlugin', () => { const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(1) - const pathValue = results[0]?.path.replaceAll('\\', '/') - const expected = path.join('.aindex', '.skills').replaceAll('\\', '/') - expect(pathValue).toBe(expected) - expect(results[0]?.basePath).toBe(mockHomeDir) + expect(results).toHaveLength(0) }) + }) - it('should return empty array when no skills exist', async () => { + describe('registerGlobalOutputFiles', () => { + it('should return empty array (no global output files)', async () => { const ctx = createMockOutputPluginContext({ workspace: { directory: createMockRelativePath('.', mockWorkspaceDir), projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - } + }, + skills: [createMockSkillPrompt('test-skill', 'content')] }) - const results = await plugin.registerGlobalOutputDirs(ctx) + const results = await plugin.registerGlobalOutputFiles(ctx) + expect(results).toHaveLength(0) }) }) - describe('registerGlobalOutputFiles', () => { - it('should register SKILL.md in ~/.skills/ for each skill', async () => { + describe('registerProjectOutputFiles', () => { + it('should register skill files for each skill in each project', async () => { const ctx = createMockOutputPluginContext({ workspace: { directory: createMockRelativePath('.', mockWorkspaceDir), @@ -269,12 +271,11 @@ describe('genericSkillsOutputPlugin', () => { ] }) - const results = await plugin.registerGlobalOutputFiles(ctx) + const results = await plugin.registerProjectOutputFiles(ctx) - expect(results).toHaveLength(2) - expect(results[0]?.path).toBe(path.join('.aindex', '.skills', 'skill-a', 'SKILL.md')) - expect(results[1]?.path).toBe(path.join('.aindex', '.skills', 'skill-b', 'SKILL.md')) - expect(results[0]?.basePath).toBe(mockHomeDir) + expect(results).toHaveLength(2) // 2 skills * 1 file each = 2 files + expect(results[0]?.path).toBe(path.join('.agents', 'skills', 'skill-a', 'SKILL.md')) + expect(results[1]?.path).toBe(path.join('.agents', 'skills', 'skill-b', 'SKILL.md')) }) it('should register mcp.json when skill has MCP config', async () => { @@ -286,66 +287,52 @@ describe('genericSkillsOutputPlugin', () => { skills: [createMockSkillPrompt('test-skill', 'content', {mcpConfig: {rawContent: '{}'}})] }) - const results = await plugin.registerGlobalOutputFiles(ctx) + const results = await plugin.registerProjectOutputFiles(ctx) expect(results).toHaveLength(2) - expect(results[0]?.path).toBe(path.join('.aindex', '.skills', 'test-skill', 'SKILL.md')) - expect(results[1]?.path).toBe(path.join('.aindex', '.skills', 'test-skill', 'mcp.json')) + expect(results[0]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'SKILL.md')) + expect(results[1]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'mcp.json')) }) - }) - describe('writeGlobalOutputs', () => { - it('should write SKILL.md to ~/.skills/ with front matter', async () => { - const ctx = createMockOutputWriteContext({ + it('should register child docs', async () => { + const ctx = createMockOutputPluginContext({ workspace: { directory: createMockRelativePath('.', mockWorkspaceDir), projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] }, - skills: [createMockSkillPrompt('test-skill', '# Skill Content', { - description: 'A test skill', - keywords: ['test', 'demo'] + skills: [createMockSkillPrompt('test-skill', 'content', { + childDocs: [{relativePath: 'doc1.mdx', content: 'doc content'}] })] }) - const results = await plugin.writeGlobalOutputs(ctx) - - expect(results.files).toHaveLength(1) - expect(results.files[0]?.success).toBe(true) - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] - expect(writeCall).toBeDefined() - expect(writeCall?.[0]).toContain(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill', 'SKILL.md')) + const results = await plugin.registerProjectOutputFiles(ctx) - const writtenContent = writeCall?.[1] as string - expect(writtenContent).toContain('name: test-skill') - expect(writtenContent).toContain('description: A test skill') - expect(writtenContent).toContain('# Skill Content') + expect(results).toHaveLength(2) + expect(results[0]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'SKILL.md')) + expect(results[1]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'doc1.md')) }) - it('should support dry-run mode', async () => { - const ctx = createMockOutputWriteContext( - { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [createMockSkillPrompt('test-skill', 'content')] + it('should register resources', async () => { + const ctx = createMockOutputPluginContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] }, - true - ) + skills: [createMockSkillPrompt('test-skill', 'content', { + resources: [{relativePath: 'resource.json', content: '{}', encoding: 'text'}] + })] + }) - const results = await plugin.writeGlobalOutputs(ctx) + const results = await plugin.registerProjectOutputFiles(ctx) - expect(results.files).toHaveLength(1) - expect(results.files[0]?.success).toBe(true) - expect(results.files[0]?.skipped).toBe(false) - expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() + expect(results).toHaveLength(2) + expect(results[0]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'SKILL.md')) + expect(results[1]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'resource.json')) }) }) describe('writeProjectOutputs', () => { - it('should create symlinks for each skill in each project', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false) // Symlink doesn't exist yet + it('should write skill files directly to project directory', async () => { const ctx = createMockOutputWriteContext({ workspace: { directory: createMockRelativePath('.', mockWorkspaceDir), @@ -359,22 +346,19 @@ describe('genericSkillsOutputPlugin', () => { const results = await plugin.writeProjectOutputs(ctx) - expect(results.files).toHaveLength(2) - expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledTimes(2) + expect(results.files).toHaveLength(2) // 2 projects * 1 skill = 2 files + expect(results.files[0]?.success).toBe(true) + expect(results.files[1]?.success).toBe(true) - expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledWith( // Verify symlinks point from project to global - expect.stringContaining(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill')), - expect.stringContaining(path.join('project1', '.skills', 'test-skill')), - expect.anything() - ) - expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledWith( - expect.stringContaining(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill')), - expect.stringContaining(path.join('project2', '.skills', 'test-skill')), - expect.anything() - ) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalled() // Verify files are written (not symlinks created) + expect(vi.mocked(fs.symlinkSync)).not.toHaveBeenCalled() + + const writeCalls = vi.mocked(fs.writeFileSync).mock.calls // Verify correct paths + expect(writeCalls[0]?.[0]).toContain(path.join('project1', '.agents', 'skills', 'test-skill', 'SKILL.md')) + expect(writeCalls[1]?.[0]).toContain(path.join('project2', '.agents', 'skills', 'test-skill', 'SKILL.md')) }) - it('should support dry-run mode for symlinks', async () => { + it('should support dry-run mode', async () => { const ctx = createMockOutputWriteContext( { workspace: { @@ -390,8 +374,7 @@ describe('genericSkillsOutputPlugin', () => { expect(results.files).toHaveLength(1) expect(results.files[0]?.success).toBe(true) - expect(results.files[0]?.skipped).toBe(false) - expect(vi.mocked(fs.symlinkSync)).not.toHaveBeenCalled() + expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() }) it('should skip project without dirFromWorkspacePath', async () => { @@ -406,7 +389,6 @@ describe('genericSkillsOutputPlugin', () => { const results = await plugin.writeProjectOutputs(ctx) expect(results.files).toHaveLength(0) - expect(vi.mocked(fs.symlinkSync)).not.toHaveBeenCalled() }) it('should return empty results when no skills exist', async () => { @@ -422,26 +404,47 @@ describe('genericSkillsOutputPlugin', () => { expect(results.files).toHaveLength(0) expect(results.dirs).toHaveLength(0) }) + + it('should write skill with front matter', async () => { + const ctx = createMockOutputWriteContext({ + workspace: { + directory: createMockRelativePath('.', mockWorkspaceDir), + projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] + }, + skills: [createMockSkillPrompt('test-skill', '# Skill Content', { + description: 'A test skill', + keywords: ['test', 'demo'] + })] + }) + + await plugin.writeProjectOutputs(ctx) + + const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] + expect(writeCall).toBeDefined() + expect(writeCall?.[0]).toContain(path.join('project1', '.agents', 'skills', 'test-skill', 'SKILL.md')) + + const writtenContent = writeCall?.[1] as string + expect(writtenContent).toContain('name: test-skill') + expect(writtenContent).toContain('description: A test skill') + expect(writtenContent).toContain('# Skill Content') + }) }) - describe('registerProjectOutputFiles', () => { - it('should register symlink paths for each skill in each project', async () => { - const ctx = createMockOutputPluginContext({ + describe('writeGlobalOutputs', () => { + it('should return empty results (no global output)', async () => { + const ctx = createMockOutputWriteContext({ workspace: { directory: createMockRelativePath('.', mockWorkspaceDir), projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] }, - skills: [ - createMockSkillPrompt('skill-a', 'content a'), - createMockSkillPrompt('skill-b', 'content b') - ] + skills: [createMockSkillPrompt('test-skill', 'content')] }) - const results = await plugin.registerProjectOutputFiles(ctx) + const results = await plugin.writeGlobalOutputs(ctx) - expect(results).toHaveLength(2) - expect(results[0]?.path).toBe(path.join('.skills', 'skill-a')) - expect(results[1]?.path).toBe(path.join('.skills', 'skill-b')) + expect(results.files).toHaveLength(0) + expect(results.dirs).toHaveLength(0) + expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() }) }) }) diff --git a/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts index 8a53d64e..2899d354 100644 --- a/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts +++ b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts @@ -13,39 +13,36 @@ import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' import {FilePathKind} from '@truenine/plugin-shared' -const PROJECT_SKILLS_DIR = '.skills' -const GLOBAL_SKILLS_DIR = '.aindex/.skills' -const OLD_GLOBAL_SKILLS_DIR = '.skills' // 向后兼容:旧的全局 skills 目录 +const PROJECT_SKILLS_DIR = '.agents/skills' +const LEGACY_SKILLS_DIR = '.skills' // 旧路径,用于清理 const SKILL_FILE_NAME = 'SKILL.md' const MCP_CONFIG_FILE = 'mcp.json' /** - * Output plugin that writes skills to a global location (~/.skills/) and - * creates symlinks in each project pointing to the global skill directories. - * - * This approach reduces disk space usage when multiple projects use the same skills. + * Output plugin that writes skills directly to each project's .agents/skills/ directory. * * Structure: - * - Global: ~/.skills//SKILL.md, mcp.json, child docs, resources - * - Project: /.skills/ → ~/.skills/ (symlink) + * - Project: /.agents/skills//SKILL.md, mcp.json, child docs, resources + * + * Also cleans up legacy .skills/ directories from previous versions. */ export class GenericSkillsOutputPlugin extends AbstractOutputPlugin { constructor() { - super('GenericSkillsOutputPlugin', {globalConfigDir: GLOBAL_SKILLS_DIR, outputFileName: SKILL_FILE_NAME}) + super('GenericSkillsOutputPlugin', {outputFileName: SKILL_FILE_NAME}) this.registerCleanEffect('legacy-global-skills-cleanup', async ctx => { // 向后兼容:clean 时清理旧的 ~/.skills 目录 - const oldGlobalSkillsDir = this.joinPath(this.getHomeDir(), OLD_GLOBAL_SKILLS_DIR) - if (!this.existsSync(oldGlobalSkillsDir)) return {success: true, description: 'Legacy global skills dir does not exist, nothing to clean'} + const legacyGlobalSkillsDir = this.joinPath(this.getHomeDir(), LEGACY_SKILLS_DIR) + if (!this.existsSync(legacyGlobalSkillsDir)) return {success: true, description: 'Legacy global skills dir does not exist, nothing to clean'} if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'legacyCleanup', path: oldGlobalSkillsDir}) - return {success: true, description: `Would clean legacy global skills dir: ${oldGlobalSkillsDir}`} + this.log.trace({action: 'dryRun', type: 'legacyCleanup', path: legacyGlobalSkillsDir}) + return {success: true, description: `Would clean legacy global skills dir: ${legacyGlobalSkillsDir}`} } try { - const entries = this.readdirSync(oldGlobalSkillsDir, {withFileTypes: true}) // 只删除 skill 子目录(避免误删用户其他文件) + const entries = this.readdirSync(legacyGlobalSkillsDir, {withFileTypes: true}) // 只删除 skill 子目录(避免误删用户其他文件) let cleanedCount = 0 for (const entry of entries) { if (entry.isDirectory()) { - const skillDir = this.joinPath(oldGlobalSkillsDir, entry.name) + const skillDir = this.joinPath(legacyGlobalSkillsDir, entry.name) const skillFile = this.joinPath(skillDir, SKILL_FILE_NAME) if (this.existsSync(skillFile)) { // 确认是 skill 目录(包含 SKILL.md)才删除 fs.rmSync(skillDir, {recursive: true}) @@ -53,23 +50,19 @@ export class GenericSkillsOutputPlugin extends AbstractOutputPlugin { } } } - const remainingEntries = this.readdirSync(oldGlobalSkillsDir) // 如果目录为空则删除目录本身 - if (remainingEntries.length === 0) fs.rmdirSync(oldGlobalSkillsDir) - this.log.trace({action: 'clean', type: 'legacySkills', dir: oldGlobalSkillsDir, cleanedCount}) - return {success: true, description: `Cleaned ${cleanedCount} legacy skills from ${oldGlobalSkillsDir}`} + const remainingEntries = this.readdirSync(legacyGlobalSkillsDir) // 如果目录为空则删除目录本身 + if (remainingEntries.length === 0) fs.rmdirSync(legacyGlobalSkillsDir) + this.log.trace({action: 'clean', type: 'legacySkills', dir: legacyGlobalSkillsDir, cleanedCount}) + return {success: true, description: `Cleaned ${cleanedCount} legacy skills from ${legacyGlobalSkillsDir}`} } catch (error) { const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'clean', type: 'legacySkills', dir: oldGlobalSkillsDir, error: errMsg}) + this.log.error({action: 'clean', type: 'legacySkills', dir: legacyGlobalSkillsDir, error: errMsg}) return {success: false, description: `Failed to clean legacy skills dir`, error: error as Error} } }) } - private getGlobalSkillsDir(): string { - return this.joinPath(this.getHomeDir(), GLOBAL_SKILLS_DIR) - } - async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { const results: RelativePath[] = [] const {projects} = ctx.collectedInputContext.workspace @@ -77,10 +70,10 @@ export class GenericSkillsOutputPlugin extends AbstractOutputPlugin { if (skills == null || skills.length === 0) return results - for (const project of projects) { // Register /.skills/ for cleanup (symlink directory) + for (const project of projects) { if (project.dirFromWorkspacePath == null) continue - const skillsDir = this.joinPath(project.dirFromWorkspacePath.path, PROJECT_SKILLS_DIR) + const skillsDir = this.joinPath(project.dirFromWorkspacePath.path, PROJECT_SKILLS_DIR) // 注册新的 .agents/skills/ 目录 results.push({ pathKind: FilePathKind.Relative, path: skillsDir, @@ -88,6 +81,15 @@ export class GenericSkillsOutputPlugin extends AbstractOutputPlugin { getDirectoryName: () => PROJECT_SKILLS_DIR, getAbsolutePath: () => this.joinPath(project.dirFromWorkspacePath!.basePath, skillsDir) }) + + const legacySkillsDir = this.joinPath(project.dirFromWorkspacePath.path, LEGACY_SKILLS_DIR) // 注册旧的 .skills/ 目录用于清理 + results.push({ + pathKind: FilePathKind.Relative, + path: legacySkillsDir, + basePath: project.dirFromWorkspacePath.basePath, + getDirectoryName: () => LEGACY_SKILLS_DIR, + getAbsolutePath: () => this.joinPath(project.dirFromWorkspacePath!.basePath, legacySkillsDir) + }) } return results @@ -100,95 +102,60 @@ export class GenericSkillsOutputPlugin extends AbstractOutputPlugin { if (skills == null || skills.length === 0) return results - for (const project of projects) { // Register symlink paths (skills in project are now symlinks) + for (const project of projects) { if (project.dirFromWorkspacePath == null) continue - const projectSkillsDir = this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path, PROJECT_SKILLS_DIR) + const projectSkillsDir = this.joinPath( + project.dirFromWorkspacePath.basePath, + project.dirFromWorkspacePath.path, + PROJECT_SKILLS_DIR + ) for (const skill of skills) { const skillName = skill.yamlFrontMatter.name const skillDir = this.joinPath(projectSkillsDir, skillName) - results.push({ // Register skill directory symlink + results.push({ // 注册 SKILL.md pathKind: FilePathKind.Relative, - path: this.joinPath(PROJECT_SKILLS_DIR, skillName), + path: this.joinPath(PROJECT_SKILLS_DIR, skillName, SKILL_FILE_NAME), basePath: this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path), getDirectoryName: () => skillName, - getAbsolutePath: () => skillDir + getAbsolutePath: () => this.joinPath(skillDir, SKILL_FILE_NAME) }) - } - } - - return results - } - - async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { - const {skills} = ctx.collectedInputContext - - if (skills == null || skills.length === 0) return [] - - const globalSkillsDir = this.getGlobalSkillsDir() - return [{ - pathKind: FilePathKind.Relative, - path: GLOBAL_SKILLS_DIR, - basePath: this.getHomeDir(), - getDirectoryName: () => GLOBAL_SKILLS_DIR, - getAbsolutePath: () => globalSkillsDir - }] - } - - async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {skills} = ctx.collectedInputContext - - if (skills == null || skills.length === 0) return results - - const globalSkillsDir = this.getGlobalSkillsDir() - - for (const skill of skills) { - const skillName = skill.yamlFrontMatter.name - const skillDir = this.joinPath(globalSkillsDir, skillName) - - results.push({ // Register SKILL.md - pathKind: FilePathKind.Relative, - path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, SKILL_FILE_NAME), - basePath: this.getHomeDir(), - getDirectoryName: () => skillName, - getAbsolutePath: () => this.joinPath(skillDir, SKILL_FILE_NAME) - }) - - if (skill.mcpConfig != null) { // Register mcp.json if skill has MCP configuration - results.push({ - pathKind: FilePathKind.Relative, - path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, MCP_CONFIG_FILE), - basePath: this.getHomeDir(), - getDirectoryName: () => skillName, - getAbsolutePath: () => this.joinPath(skillDir, MCP_CONFIG_FILE) - }) - } - if (skill.childDocs != null) { // Register child docs (convert .mdx to .md) - for (const childDoc of skill.childDocs) { - const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') + if (skill.mcpConfig != null) { // 注册 mcp.json(如果有) results.push({ pathKind: FilePathKind.Relative, - path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, outputRelativePath), - basePath: this.getHomeDir(), + path: this.joinPath(PROJECT_SKILLS_DIR, skillName, MCP_CONFIG_FILE), + basePath: this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path), getDirectoryName: () => skillName, - getAbsolutePath: () => this.joinPath(skillDir, outputRelativePath) + getAbsolutePath: () => this.joinPath(skillDir, MCP_CONFIG_FILE) }) } - } - if (skill.resources != null) { // Register resources - for (const resource of skill.resources) { - results.push({ - pathKind: FilePathKind.Relative, - path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, resource.relativePath), - basePath: this.getHomeDir(), - getDirectoryName: () => skillName, - getAbsolutePath: () => this.joinPath(skillDir, resource.relativePath) - }) + if (skill.childDocs != null) { // 注册 child docs + for (const childDoc of skill.childDocs) { + const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') + results.push({ + pathKind: FilePathKind.Relative, + path: this.joinPath(PROJECT_SKILLS_DIR, skillName, outputRelativePath), + basePath: this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path), + getDirectoryName: () => skillName, + getAbsolutePath: () => this.joinPath(skillDir, outputRelativePath) + }) + } + } + + if (skill.resources != null) { // 注册 resources + for (const resource of skill.resources) { + results.push({ + pathKind: FilePathKind.Relative, + path: this.joinPath(PROJECT_SKILLS_DIR, skillName, resource.relativePath), + basePath: this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path), + getDirectoryName: () => skillName, + getAbsolutePath: () => this.joinPath(skillDir, resource.relativePath) + }) + } } } } @@ -196,6 +163,14 @@ export class GenericSkillsOutputPlugin extends AbstractOutputPlugin { return results } + async registerGlobalOutputDirs(): Promise { + return [] // 不再使用全局输出目录 + } + + async registerGlobalOutputFiles(): Promise { + return [] // 不再使用全局输出文件 + } + async canWrite(ctx: OutputWriteContext): Promise { const {skills} = ctx.collectedInputContext const {projects} = ctx.collectedInputContext.workspace @@ -219,63 +194,26 @@ export class GenericSkillsOutputPlugin extends AbstractOutputPlugin { if (skills == null || skills.length === 0) return {files: fileResults, dirs: dirResults} - const globalSkillsDir = this.getGlobalSkillsDir() - - for (const project of projects) { // Create symlinks for each project + for (const project of projects) { if (project.dirFromWorkspacePath == null) continue - const projectSkillsDir = this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path, PROJECT_SKILLS_DIR) + const projectSkillsDir = this.joinPath( + project.dirFromWorkspacePath.basePath, + project.dirFromWorkspacePath.path, + PROJECT_SKILLS_DIR + ) for (const skill of skills) { - const skillName = skill.yamlFrontMatter.name - const globalSkillDir = this.joinPath(globalSkillsDir, skillName) - const projectSkillDir = this.joinPath(projectSkillsDir, skillName) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: this.joinPath(PROJECT_SKILLS_DIR, skillName), - basePath: this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path), - getDirectoryName: () => skillName, - getAbsolutePath: () => projectSkillDir - } - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'symlink', target: globalSkillDir, link: projectSkillDir}) - fileResults.push({path: relativePath, success: true, skipped: false}) - continue - } - - try { - this.createSymlink(globalSkillDir, projectSkillDir, 'dir') - this.log.trace({action: 'symlink', type: 'skill', target: globalSkillDir, link: projectSkillDir}) - fileResults.push({path: relativePath, success: true}) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'symlink', type: 'skill', target: globalSkillDir, link: projectSkillDir, error: errMsg}) - fileResults.push({path: relativePath, success: false, error: error as Error}) - } + const skillResults = await this.writeSkill(ctx, skill, projectSkillsDir) // 将技能文件直接写入项目目录 + fileResults.push(...skillResults) } } return {files: fileResults, dirs: dirResults} } - async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {skills} = ctx.collectedInputContext - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - if (skills == null || skills.length === 0) return {files: fileResults, dirs: dirResults} - - const globalSkillsDir = this.getGlobalSkillsDir() - - for (const skill of skills) { // Write all skills to global ~/.skills/ directory - const skillResults = await this.writeSkill(ctx, skill, globalSkillsDir) - fileResults.push(...skillResults) - } - - return {files: fileResults, dirs: dirResults} + async writeGlobalOutputs(): Promise { + return {files: [], dirs: []} // 不再写入全局输出,所有技能文件直接写入项目目录 } private async writeSkill( From b850e2b05715e85f03aef8dee1cef6365fa5bfe2 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 00:30:22 +0800 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E5=BA=9F?= =?UTF-8?q?=E5=BC=83=E6=8F=92=E4=BB=B6=E5=8F=8A=E7=9B=B8=E5=85=B3=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=8C=E4=BC=98=E5=8C=96=E6=A8=A1=E5=9D=97=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除多个废弃插件包及其配置文件 - 将外部模块引用改为本地相对路径 - 清理不再使用的依赖项和构建配置 --- cli/src/commands/CleanupUtils.ts | 6 +- cli/src/commands/Command.ts | 2 +- cli/src/commands/CommandUtils.ts | 4 +- cli/src/commands/DryRunCleanCommand.ts | 2 +- cli/src/commands/DryRunOutputCommand.ts | 2 +- cli/src/commands/ExecuteCommand.ts | 2 +- cli/src/commands/PluginsCommand.ts | 2 +- .../typeSpecificFilters.property.test.ts | 4 +- cli/src/utils/RelativePathFactory.ts | 4 +- cli/src/utils/ResourceUtils.ts | 4 +- cli/src/utils/WriteHelper.ts | 6 +- cli/src/utils/ruleFilter.ts | 4 +- packages/desk-paths/eslint.config.ts | 33 - packages/desk-paths/package.json | 27 - .../desk-paths/src/index.property.test.ts | 174 ---- packages/desk-paths/src/index.ts | 401 --------- packages/desk-paths/tsconfig.eslint.json | 23 - packages/desk-paths/tsconfig.json | 70 -- packages/desk-paths/tsconfig.lib.json | 21 - packages/desk-paths/tsconfig.test.json | 25 - packages/desk-paths/tsdown.config.ts | 18 - packages/desk-paths/vite.config.ts | 10 - packages/desk-paths/vitest.config.ts | 32 - .../eslint.config.ts | 17 - .../plugin-agentskills-compact/package.json | 31 - .../src/GenericSkillsOutputPlugin.test.ts | 447 ---------- .../src/GenericSkillsOutputPlugin.ts | 468 ---------- .../plugin-agentskills-compact/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-agentskills-compact/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../plugin-agentskills-compact/vite.config.ts | 8 - .../vitest.config.ts | 23 - packages/plugin-agentsmd/eslint.config.ts | 17 - packages/plugin-agentsmd/package.json | 30 - .../plugin-agentsmd/src/AgentsOutputPlugin.ts | 74 -- packages/plugin-agentsmd/src/index.ts | 3 - packages/plugin-agentsmd/tsconfig.eslint.json | 7 - packages/plugin-agentsmd/tsconfig.json | 54 -- packages/plugin-agentsmd/tsconfig.lib.json | 13 - packages/plugin-agentsmd/tsconfig.test.json | 7 - packages/plugin-agentsmd/tsdown.config.ts | 16 - packages/plugin-agentsmd/vite.config.ts | 8 - packages/plugin-agentsmd/vitest.config.ts | 23 - packages/plugin-antigravity/eslint.config.ts | 17 - packages/plugin-antigravity/package.json | 30 - .../src/AntigravityOutputPlugin.test.ts | 343 -------- .../src/AntigravityOutputPlugin.ts | 216 ----- packages/plugin-antigravity/src/index.ts | 3 - .../plugin-antigravity/tsconfig.eslint.json | 7 - packages/plugin-antigravity/tsconfig.json | 54 -- packages/plugin-antigravity/tsconfig.lib.json | 7 - .../plugin-antigravity/tsconfig.test.json | 7 - packages/plugin-antigravity/tsdown.config.ts | 16 - packages/plugin-antigravity/vite.config.ts | 8 - packages/plugin-antigravity/vitest.config.ts | 23 - .../plugin-claude-code-cli/eslint.config.ts | 17 - packages/plugin-claude-code-cli/package.json | 31 - ...eCodeCLIOutputPlugin.projectConfig.test.ts | 214 ----- ...ClaudeCodeCLIOutputPlugin.property.test.ts | 161 ---- .../src/ClaudeCodeCLIOutputPlugin.test.ts | 504 ----------- .../src/ClaudeCodeCLIOutputPlugin.ts | 135 --- packages/plugin-claude-code-cli/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - packages/plugin-claude-code-cli/tsconfig.json | 54 -- .../plugin-claude-code-cli/tsconfig.lib.json | 7 - .../plugin-claude-code-cli/tsconfig.test.json | 7 - .../plugin-claude-code-cli/tsdown.config.ts | 16 - .../plugin-claude-code-cli/vite.config.ts | 8 - .../plugin-claude-code-cli/vitest.config.ts | 23 - packages/plugin-cursor/eslint.config.ts | 17 - packages/plugin-cursor/package.json | 32 - .../CursorOutputPlugin.projectConfig.test.ts | 214 ----- .../src/CursorOutputPlugin.test.ts | 833 ------------------ .../plugin-cursor/src/CursorOutputPlugin.ts | 534 ----------- packages/plugin-cursor/src/index.ts | 3 - packages/plugin-cursor/tsconfig.eslint.json | 7 - packages/plugin-cursor/tsconfig.json | 54 -- packages/plugin-cursor/tsconfig.lib.json | 7 - packages/plugin-cursor/tsconfig.test.json | 7 - packages/plugin-cursor/tsdown.config.ts | 16 - packages/plugin-cursor/vite.config.ts | 8 - packages/plugin-cursor/vitest.config.ts | 23 - packages/plugin-droid-cli/eslint.config.ts | 17 - packages/plugin-droid-cli/package.json | 30 - .../src/DroidCLIOutputPlugin.test.ts | 269 ------ .../src/DroidCLIOutputPlugin.ts | 58 -- packages/plugin-droid-cli/src/index.ts | 3 - .../plugin-droid-cli/tsconfig.eslint.json | 7 - packages/plugin-droid-cli/tsconfig.json | 54 -- packages/plugin-droid-cli/tsconfig.lib.json | 7 - packages/plugin-droid-cli/tsconfig.test.json | 7 - packages/plugin-droid-cli/tsdown.config.ts | 16 - packages/plugin-droid-cli/vite.config.ts | 8 - packages/plugin-droid-cli/vitest.config.ts | 23 - packages/plugin-editorconfig/eslint.config.ts | 17 - packages/plugin-editorconfig/package.json | 30 - .../src/EditorConfigOutputPlugin.ts | 79 -- packages/plugin-editorconfig/src/index.ts | 3 - .../plugin-editorconfig/tsconfig.eslint.json | 7 - packages/plugin-editorconfig/tsconfig.json | 54 -- .../plugin-editorconfig/tsconfig.lib.json | 7 - .../plugin-editorconfig/tsconfig.test.json | 7 - packages/plugin-editorconfig/tsdown.config.ts | 16 - packages/plugin-editorconfig/vite.config.ts | 8 - packages/plugin-editorconfig/vitest.config.ts | 23 - packages/plugin-gemini-cli/eslint.config.ts | 17 - packages/plugin-gemini-cli/package.json | 30 - .../src/GeminiCLIOutputPlugin.ts | 16 - packages/plugin-gemini-cli/src/index.ts | 3 - .../plugin-gemini-cli/tsconfig.eslint.json | 7 - packages/plugin-gemini-cli/tsconfig.json | 54 -- packages/plugin-gemini-cli/tsconfig.lib.json | 13 - packages/plugin-gemini-cli/tsconfig.test.json | 7 - packages/plugin-gemini-cli/tsdown.config.ts | 16 - packages/plugin-gemini-cli/vite.config.ts | 8 - packages/plugin-gemini-cli/vitest.config.ts | 23 - packages/plugin-git-exclude/eslint.config.ts | 17 - packages/plugin-git-exclude/package.json | 30 - .../src/GitExcludeOutputPlugin.test.ts | 265 ------ .../src/GitExcludeOutputPlugin.ts | 275 ------ packages/plugin-git-exclude/src/index.ts | 3 - .../plugin-git-exclude/tsconfig.eslint.json | 7 - packages/plugin-git-exclude/tsconfig.json | 54 -- packages/plugin-git-exclude/tsconfig.lib.json | 7 - .../plugin-git-exclude/tsconfig.test.json | 7 - packages/plugin-git-exclude/tsdown.config.ts | 16 - packages/plugin-git-exclude/vite.config.ts | 8 - packages/plugin-git-exclude/vitest.config.ts | 23 - .../plugin-input-agentskills/eslint.config.ts | 17 - .../plugin-input-agentskills/package.json | 31 - .../src/SkillInputPlugin.test.ts | 309 ------- .../src/SkillInputPlugin.ts | 476 ---------- .../plugin-input-agentskills/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-input-agentskills/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../plugin-input-agentskills/tsdown.config.ts | 16 - .../plugin-input-agentskills/vite.config.ts | 8 - .../plugin-input-agentskills/vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../plugin-input-editorconfig/package.json | 30 - .../src/EditorConfigInputPlugin.ts | 44 - .../plugin-input-editorconfig/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-input-editorconfig/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../plugin-input-editorconfig/vite.config.ts | 8 - .../vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../plugin-input-fast-command/package.json | 31 - .../src/FastCommandInputPlugin.test.ts | 131 --- .../src/FastCommandInputPlugin.ts | 200 ----- .../plugin-input-fast-command/src/index.ts | 6 - .../tsconfig.eslint.json | 7 - .../plugin-input-fast-command/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../plugin-input-fast-command/vite.config.ts | 8 - .../vitest.config.ts | 23 - .../plugin-input-git-exclude/eslint.config.ts | 17 - .../plugin-input-git-exclude/package.json | 30 - .../src/GitExcludeInputPlugin.test.ts | 78 -- .../src/GitExcludeInputPlugin.ts | 23 - .../plugin-input-git-exclude/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-input-git-exclude/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../plugin-input-git-exclude/tsdown.config.ts | 16 - .../plugin-input-git-exclude/vite.config.ts | 8 - .../plugin-input-git-exclude/vitest.config.ts | 23 - .../plugin-input-gitignore/eslint.config.ts | 17 - packages/plugin-input-gitignore/package.json | 31 - .../src/GitIgnoreInputPlugin.test.ts | 66 -- .../src/GitIgnoreInputPlugin.ts | 30 - packages/plugin-input-gitignore/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - packages/plugin-input-gitignore/tsconfig.json | 54 -- .../plugin-input-gitignore/tsconfig.lib.json | 7 - .../plugin-input-gitignore/tsconfig.test.json | 7 - .../plugin-input-gitignore/tsdown.config.ts | 16 - .../plugin-input-gitignore/vite.config.ts | 8 - .../plugin-input-gitignore/vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../plugin-input-global-memory/package.json | 31 - .../src/GlobalMemoryInputPlugin.ts | 87 -- .../plugin-input-global-memory/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-input-global-memory/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../plugin-input-global-memory/vite.config.ts | 8 - .../vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../package.json | 30 - .../src/JetBrainsConfigInputPlugin.ts | 52 -- .../src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../vite.config.ts | 8 - .../vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../package.json | 31 - ...eCleanupEffectInputPlugin.property.test.ts | 311 ------- ...kdownWhitespaceCleanupEffectInputPlugin.ts | 153 ---- .../src/index.ts | 6 - .../tsconfig.eslint.json | 7 - .../tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../vite.config.ts | 8 - .../vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../package.json | 31 - ...eCleanupEffectInputPlugin.property.test.ts | 263 ------ .../src/OrphanFileCleanupEffectInputPlugin.ts | 214 ----- .../src/index.ts | 6 - .../tsconfig.eslint.json | 7 - .../tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../vite.config.ts | 8 - .../vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../plugin-input-project-prompt/package.json | 31 - .../src/ProjectPromptInputPlugin.test.ts | 214 ----- .../src/ProjectPromptInputPlugin.ts | 235 ----- .../plugin-input-project-prompt/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-input-project-prompt/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../vite.config.ts | 8 - .../vitest.config.ts | 23 - packages/plugin-input-readme/eslint.config.ts | 17 - packages/plugin-input-readme/package.json | 31 - .../src/ReadmeMdInputPlugin.property.test.ts | 365 -------- .../src/ReadmeMdInputPlugin.ts | 155 ---- packages/plugin-input-readme/src/index.ts | 3 - .../plugin-input-readme/tsconfig.eslint.json | 7 - packages/plugin-input-readme/tsconfig.json | 54 -- .../plugin-input-readme/tsconfig.lib.json | 7 - .../plugin-input-readme/tsconfig.test.json | 7 - packages/plugin-input-readme/tsdown.config.ts | 16 - packages/plugin-input-readme/vite.config.ts | 8 - packages/plugin-input-readme/vitest.config.ts | 23 - packages/plugin-input-rule/eslint.config.ts | 17 - packages/plugin-input-rule/package.json | 31 - .../src/RuleInputPlugin.test.ts | 322 ------- .../plugin-input-rule/src/RuleInputPlugin.ts | 176 ---- packages/plugin-input-rule/src/index.ts | 3 - .../plugin-input-rule/tsconfig.eslint.json | 7 - packages/plugin-input-rule/tsconfig.json | 54 -- packages/plugin-input-rule/tsconfig.lib.json | 7 - packages/plugin-input-rule/tsconfig.test.json | 7 - packages/plugin-input-rule/tsdown.config.ts | 16 - packages/plugin-input-rule/vite.config.ts | 8 - packages/plugin-input-rule/vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../plugin-input-shadow-project/package.json | 31 - .../src/ShadowProjectInputPlugin.test.ts | 164 ---- .../src/ShadowProjectInputPlugin.ts | 118 --- .../plugin-input-shadow-project/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-input-shadow-project/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 17 - .../vite.config.ts | 8 - .../vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../plugin-input-shared-ignore/package.json | 30 - .../src/AIAgentIgnoreInputPlugin.ts | 47 - .../plugin-input-shared-ignore/src/index.ts | 3 - .../plugin-input-shared-ignore/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsdown.config.ts | 16 - packages/plugin-input-shared/eslint.config.ts | 24 - packages/plugin-input-shared/package.json | 35 - .../src/AbstractInputPlugin.test.ts | 357 -------- .../src/AbstractInputPlugin.ts | 147 ---- .../src/BaseDirectoryInputPlugin.ts | 144 --- .../src/BaseFileInputPlugin.ts | 57 -- packages/plugin-input-shared/src/index.ts | 15 - .../src/scope/GlobalScopeCollector.ts | 117 --- .../src/scope/ScopeRegistry.ts | 114 --- .../plugin-input-shared/src/scope/index.ts | 14 - .../plugin-input-shared/tsconfig.eslint.json | 23 - packages/plugin-input-shared/tsconfig.json | 70 -- .../plugin-input-shared/tsconfig.lib.json | 21 - .../plugin-input-shared/tsconfig.test.json | 25 - packages/plugin-input-shared/tsdown.config.ts | 22 - packages/plugin-input-shared/vite.config.ts | 10 - packages/plugin-input-shared/vitest.config.ts | 33 - .../eslint.config.ts | 17 - .../package.json | 31 - ...FileSyncEffectInputPlugin.property.test.ts | 261 ------ .../SkillNonSrcFileSyncEffectInputPlugin.ts | 182 ---- .../src/index.ts | 6 - .../tsconfig.eslint.json | 7 - .../tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../vite.config.ts | 8 - .../vitest.config.ts | 23 - .../plugin-input-subagent/eslint.config.ts | 17 - packages/plugin-input-subagent/package.json | 31 - .../src/SubAgentInputPlugin.test.ts | 137 --- .../src/SubAgentInputPlugin.ts | 200 ----- packages/plugin-input-subagent/src/index.ts | 6 - .../tsconfig.eslint.json | 7 - packages/plugin-input-subagent/tsconfig.json | 54 -- .../plugin-input-subagent/tsconfig.lib.json | 7 - .../plugin-input-subagent/tsconfig.test.json | 7 - .../plugin-input-subagent/tsdown.config.ts | 16 - packages/plugin-input-subagent/vite.config.ts | 8 - .../plugin-input-subagent/vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../plugin-input-vscode-config/package.json | 30 - .../src/VSCodeConfigInputPlugin.ts | 48 - .../plugin-input-vscode-config/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-input-vscode-config/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../plugin-input-vscode-config/vite.config.ts | 8 - .../vitest.config.ts | 23 - .../plugin-input-workspace/eslint.config.ts | 17 - packages/plugin-input-workspace/package.json | 30 - .../src/WorkspaceInputPlugin.ts | 31 - packages/plugin-input-workspace/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - packages/plugin-input-workspace/tsconfig.json | 54 -- .../plugin-input-workspace/tsconfig.lib.json | 7 - .../plugin-input-workspace/tsconfig.test.json | 7 - .../plugin-input-workspace/tsdown.config.ts | 16 - .../plugin-input-workspace/vite.config.ts | 8 - .../plugin-input-workspace/vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../plugin-jetbrains-ai-codex/package.json | 32 - ...BrainsAIAssistantCodexOutputPlugin.test.ts | 391 -------- .../JetBrainsAIAssistantCodexOutputPlugin.ts | 607 ------------- .../plugin-jetbrains-ai-codex/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-jetbrains-ai-codex/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../plugin-jetbrains-ai-codex/vite.config.ts | 8 - .../vitest.config.ts | 23 - .../eslint.config.ts | 17 - .../plugin-jetbrains-codestyle/package.json | 30 - ...JetBrainsIDECodeStyleConfigOutputPlugin.ts | 144 --- .../plugin-jetbrains-codestyle/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-jetbrains-codestyle/tsconfig.json | 54 -- .../tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../tsdown.config.ts | 16 - .../plugin-jetbrains-codestyle/vite.config.ts | 8 - .../vitest.config.ts | 23 - packages/plugin-kiro-ide/env.d.ts | 1 - packages/plugin-kiro-ide/eslint.config.ts | 17 - packages/plugin-kiro-ide/package.json | 32 - .../KiroCLIOutputPlugin.projectConfig.test.ts | 199 ----- .../src/KiroCLIOutputPlugin.test.ts | 766 ---------------- .../src/KiroCLIOutputPlugin.ts | 459 ---------- .../src/KiroPowers.integration.test.ts | 194 ---- .../KiroPowersRegistryWriter.property.test.ts | 272 ------ .../src/KiroPowersRegistryWriter.ts | 134 --- packages/plugin-kiro-ide/src/index.ts | 6 - packages/plugin-kiro-ide/tsconfig.eslint.json | 7 - packages/plugin-kiro-ide/tsconfig.json | 54 -- packages/plugin-kiro-ide/tsconfig.lib.json | 7 - packages/plugin-kiro-ide/tsconfig.test.json | 7 - packages/plugin-kiro-ide/tsdown.config.ts | 16 - packages/plugin-kiro-ide/vite.config.ts | 8 - packages/plugin-kiro-ide/vitest.config.ts | 23 - .../plugin-openai-codex-cli/eslint.config.ts | 17 - packages/plugin-openai-codex-cli/package.json | 31 - .../src/CodexCLIOutputPlugin.ts | 189 ---- packages/plugin-openai-codex-cli/src/index.ts | 3 - .../tsconfig.eslint.json | 7 - .../plugin-openai-codex-cli/tsconfig.json | 54 -- .../plugin-openai-codex-cli/tsconfig.lib.json | 7 - .../tsconfig.test.json | 7 - .../plugin-openai-codex-cli/tsdown.config.ts | 16 - .../plugin-openai-codex-cli/vite.config.ts | 8 - .../plugin-openai-codex-cli/vitest.config.ts | 23 - packages/plugin-opencode-cli/eslint.config.ts | 17 - packages/plugin-opencode-cli/package.json | 32 - ...ncodeCLIOutputPlugin.projectConfig.test.ts | 231 ----- .../OpencodeCLIOutputPlugin.property.test.ts | 158 ---- .../src/OpencodeCLIOutputPlugin.test.ts | 777 ---------------- .../src/OpencodeCLIOutputPlugin.ts | 467 ---------- packages/plugin-opencode-cli/src/index.ts | 3 - .../plugin-opencode-cli/tsconfig.eslint.json | 7 - packages/plugin-opencode-cli/tsconfig.json | 54 -- .../plugin-opencode-cli/tsconfig.lib.json | 7 - .../plugin-opencode-cli/tsconfig.test.json | 7 - packages/plugin-opencode-cli/tsdown.config.ts | 16 - packages/plugin-opencode-cli/vite.config.ts | 8 - packages/plugin-opencode-cli/vitest.config.ts | 23 - .../plugin-output-shared/eslint.config.ts | 24 - packages/plugin-output-shared/package.json | 45 - .../src/AbstractOutputPlugin.test.ts | 717 --------------- .../src/AbstractOutputPlugin.ts | 543 ------------ .../src/BaseCLIOutputPlugin.ts | 405 --------- packages/plugin-output-shared/src/index.ts | 14 - .../src/registry/RegistryWriter.ts | 149 ---- .../src/registry/index.ts | 3 - .../src/utils/commandFilter.ts | 11 - .../src/utils/gitUtils.ts | 121 --- .../plugin-output-shared/src/utils/index.ts | 25 - .../utils/pathNormalization.property.test.ts | 57 -- .../src/utils/ruleFilter.ts | 105 --- ...esFilter.napi-equivalence.property.test.ts | 107 --- .../src/utils/seriesFilter.property.test.ts | 154 ---- .../src/utils/seriesFilter.ts | 61 -- .../src/utils/skillFilter.ts | 11 - .../src/utils/subAgentFilter.ts | 11 - .../subSeriesGlobExpansion.property.test.ts | 196 ----- .../typeSpecificFilters.property.test.ts | 119 --- .../plugin-output-shared/tsconfig.eslint.json | 23 - packages/plugin-output-shared/tsconfig.json | 70 -- .../plugin-output-shared/tsconfig.lib.json | 21 - .../plugin-output-shared/tsconfig.test.json | 25 - .../plugin-output-shared/tsdown.config.ts | 23 - packages/plugin-output-shared/vite.config.ts | 10 - .../plugin-output-shared/vitest.config.ts | 33 - packages/plugin-qoder-ide/eslint.config.ts | 17 - packages/plugin-qoder-ide/package.json | 32 - ...rIDEPluginOutputPlugin.frontmatter.test.ts | 63 -- ...DEPluginOutputPlugin.projectConfig.test.ts | 118 --- .../src/QoderIDEPluginOutputPlugin.test.ts | 485 ---------- .../src/QoderIDEPluginOutputPlugin.ts | 426 --------- packages/plugin-qoder-ide/src/index.ts | 3 - .../plugin-qoder-ide/tsconfig.eslint.json | 7 - packages/plugin-qoder-ide/tsconfig.json | 54 -- packages/plugin-qoder-ide/tsconfig.lib.json | 7 - packages/plugin-qoder-ide/tsconfig.test.json | 7 - packages/plugin-qoder-ide/tsdown.config.ts | 16 - packages/plugin-qoder-ide/vite.config.ts | 8 - packages/plugin-qoder-ide/vitest.config.ts | 23 - packages/plugin-readme/eslint.config.ts | 17 - packages/plugin-readme/package.json | 30 - ...eMdConfigFileOutputPlugin.property.test.ts | 499 ----------- .../src/ReadmeMdConfigFileOutputPlugin.ts | 128 --- packages/plugin-readme/src/index.ts | 3 - packages/plugin-readme/tsconfig.eslint.json | 7 - packages/plugin-readme/tsconfig.json | 54 -- packages/plugin-readme/tsconfig.lib.json | 7 - packages/plugin-readme/tsconfig.test.json | 7 - packages/plugin-readme/tsdown.config.ts | 16 - packages/plugin-readme/vite.config.ts | 8 - packages/plugin-readme/vitest.config.ts | 23 - packages/plugin-shared/eslint.config.ts | 24 - packages/plugin-shared/package.json | 43 - packages/plugin-shared/src/AbstractPlugin.ts | 26 - packages/plugin-shared/src/PluginNames.ts | 24 - packages/plugin-shared/src/constants.ts | 11 - packages/plugin-shared/src/index.ts | 23 - packages/plugin-shared/src/log.ts | 9 - packages/plugin-shared/src/testing/index.ts | 65 -- .../types/ConfigTypes.schema.property.test.ts | 92 -- .../src/types/ConfigTypes.schema.ts | 122 --- packages/plugin-shared/src/types/Enums.ts | 75 -- packages/plugin-shared/src/types/Errors.ts | 40 - .../src/types/ExportMetadataTypes.ts | 213 ----- .../src/types/FileSystemTypes.ts | 37 - .../plugin-shared/src/types/InputTypes.ts | 417 --------- .../plugin-shared/src/types/OutputTypes.ts | 24 - .../plugin-shared/src/types/PluginTypes.ts | 390 -------- .../plugin-shared/src/types/PromptTypes.ts | 146 --- .../plugin-shared/src/types/RegistryTypes.ts | 106 --- .../src/types/ShadowSourceProjectTypes.ts | 298 ------- packages/plugin-shared/src/types/index.ts | 11 - .../seriNamePropagation.property.test.ts | 82 -- packages/plugin-shared/tsconfig.eslint.json | 23 - packages/plugin-shared/tsconfig.json | 70 -- packages/plugin-shared/tsconfig.lib.json | 21 - packages/plugin-shared/tsconfig.test.json | 25 - packages/plugin-shared/tsdown.config.ts | 23 - packages/plugin-shared/vite.config.ts | 10 - packages/plugin-shared/vitest.config.ts | 33 - packages/plugin-trae-ide/eslint.config.ts | 17 - packages/plugin-trae-ide/package.json | 30 - .../src/TraeIDEOutputPlugin.test.ts | 135 --- .../src/TraeIDEOutputPlugin.ts | 167 ---- packages/plugin-trae-ide/src/index.ts | 3 - packages/plugin-trae-ide/tsconfig.eslint.json | 7 - packages/plugin-trae-ide/tsconfig.json | 54 -- packages/plugin-trae-ide/tsconfig.lib.json | 7 - packages/plugin-trae-ide/tsconfig.test.json | 7 - packages/plugin-trae-ide/tsdown.config.ts | 16 - packages/plugin-trae-ide/vite.config.ts | 8 - packages/plugin-trae-ide/vitest.config.ts | 23 - packages/plugin-vscode/eslint.config.ts | 17 - packages/plugin-vscode/package.json | 30 - .../VisualStudioCodeIDEConfigOutputPlugin.ts | 134 --- packages/plugin-vscode/src/index.ts | 3 - packages/plugin-vscode/tsconfig.eslint.json | 7 - packages/plugin-vscode/tsconfig.json | 54 -- packages/plugin-vscode/tsconfig.lib.json | 7 - packages/plugin-vscode/tsconfig.test.json | 7 - packages/plugin-vscode/tsdown.config.ts | 16 - packages/plugin-vscode/vite.config.ts | 8 - packages/plugin-vscode/vitest.config.ts | 23 - packages/plugin-warp-ide/eslint.config.ts | 17 - packages/plugin-warp-ide/package.json | 30 - .../src/WarpIDEOutputPlugin.test.ts | 513 ----------- .../src/WarpIDEOutputPlugin.ts | 128 --- packages/plugin-warp-ide/src/index.ts | 3 - packages/plugin-warp-ide/tsconfig.eslint.json | 7 - packages/plugin-warp-ide/tsconfig.json | 54 -- packages/plugin-warp-ide/tsconfig.lib.json | 7 - packages/plugin-warp-ide/tsconfig.test.json | 7 - packages/plugin-warp-ide/tsdown.config.ts | 16 - packages/plugin-warp-ide/vite.config.ts | 8 - packages/plugin-warp-ide/vitest.config.ts | 23 - packages/plugin-windsurf/eslint.config.ts | 17 - packages/plugin-windsurf/package.json | 32 - ...WindsurfOutputPlugin.projectConfig.test.ts | 213 ----- .../src/WindsurfOutputPlugin.property.test.ts | 383 -------- .../src/WindsurfOutputPlugin.test.ts | 677 -------------- .../src/WindsurfOutputPlugin.ts | 388 -------- packages/plugin-windsurf/src/index.ts | 3 - packages/plugin-windsurf/tsconfig.eslint.json | 7 - packages/plugin-windsurf/tsconfig.json | 54 -- packages/plugin-windsurf/tsconfig.lib.json | 7 - packages/plugin-windsurf/tsconfig.test.json | 7 - packages/plugin-windsurf/tsdown.config.ts | 16 - packages/plugin-windsurf/vite.config.ts | 8 - packages/plugin-windsurf/vitest.config.ts | 23 - 550 files changed, 21 insertions(+), 33841 deletions(-) delete mode 100644 packages/desk-paths/eslint.config.ts delete mode 100644 packages/desk-paths/package.json delete mode 100644 packages/desk-paths/src/index.property.test.ts delete mode 100644 packages/desk-paths/src/index.ts delete mode 100644 packages/desk-paths/tsconfig.eslint.json delete mode 100644 packages/desk-paths/tsconfig.json delete mode 100644 packages/desk-paths/tsconfig.lib.json delete mode 100644 packages/desk-paths/tsconfig.test.json delete mode 100644 packages/desk-paths/tsdown.config.ts delete mode 100644 packages/desk-paths/vite.config.ts delete mode 100644 packages/desk-paths/vitest.config.ts delete mode 100644 packages/plugin-agentskills-compact/eslint.config.ts delete mode 100644 packages/plugin-agentskills-compact/package.json delete mode 100644 packages/plugin-agentskills-compact/src/GenericSkillsOutputPlugin.test.ts delete mode 100644 packages/plugin-agentskills-compact/src/GenericSkillsOutputPlugin.ts delete mode 100644 packages/plugin-agentskills-compact/src/index.ts delete mode 100644 packages/plugin-agentskills-compact/tsconfig.eslint.json delete mode 100644 packages/plugin-agentskills-compact/tsconfig.json delete mode 100644 packages/plugin-agentskills-compact/tsconfig.lib.json delete mode 100644 packages/plugin-agentskills-compact/tsconfig.test.json delete mode 100644 packages/plugin-agentskills-compact/tsdown.config.ts delete mode 100644 packages/plugin-agentskills-compact/vite.config.ts delete mode 100644 packages/plugin-agentskills-compact/vitest.config.ts delete mode 100644 packages/plugin-agentsmd/eslint.config.ts delete mode 100644 packages/plugin-agentsmd/package.json delete mode 100644 packages/plugin-agentsmd/src/AgentsOutputPlugin.ts delete mode 100644 packages/plugin-agentsmd/src/index.ts delete mode 100644 packages/plugin-agentsmd/tsconfig.eslint.json delete mode 100644 packages/plugin-agentsmd/tsconfig.json delete mode 100644 packages/plugin-agentsmd/tsconfig.lib.json delete mode 100644 packages/plugin-agentsmd/tsconfig.test.json delete mode 100644 packages/plugin-agentsmd/tsdown.config.ts delete mode 100644 packages/plugin-agentsmd/vite.config.ts delete mode 100644 packages/plugin-agentsmd/vitest.config.ts delete mode 100644 packages/plugin-antigravity/eslint.config.ts delete mode 100644 packages/plugin-antigravity/package.json delete mode 100644 packages/plugin-antigravity/src/AntigravityOutputPlugin.test.ts delete mode 100644 packages/plugin-antigravity/src/AntigravityOutputPlugin.ts delete mode 100644 packages/plugin-antigravity/src/index.ts delete mode 100644 packages/plugin-antigravity/tsconfig.eslint.json delete mode 100644 packages/plugin-antigravity/tsconfig.json delete mode 100644 packages/plugin-antigravity/tsconfig.lib.json delete mode 100644 packages/plugin-antigravity/tsconfig.test.json delete mode 100644 packages/plugin-antigravity/tsdown.config.ts delete mode 100644 packages/plugin-antigravity/vite.config.ts delete mode 100644 packages/plugin-antigravity/vitest.config.ts delete mode 100644 packages/plugin-claude-code-cli/eslint.config.ts delete mode 100644 packages/plugin-claude-code-cli/package.json delete mode 100644 packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts delete mode 100644 packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.property.test.ts delete mode 100644 packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.test.ts delete mode 100644 packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.ts delete mode 100644 packages/plugin-claude-code-cli/src/index.ts delete mode 100644 packages/plugin-claude-code-cli/tsconfig.eslint.json delete mode 100644 packages/plugin-claude-code-cli/tsconfig.json delete mode 100644 packages/plugin-claude-code-cli/tsconfig.lib.json delete mode 100644 packages/plugin-claude-code-cli/tsconfig.test.json delete mode 100644 packages/plugin-claude-code-cli/tsdown.config.ts delete mode 100644 packages/plugin-claude-code-cli/vite.config.ts delete mode 100644 packages/plugin-claude-code-cli/vitest.config.ts delete mode 100644 packages/plugin-cursor/eslint.config.ts delete mode 100644 packages/plugin-cursor/package.json delete mode 100644 packages/plugin-cursor/src/CursorOutputPlugin.projectConfig.test.ts delete mode 100644 packages/plugin-cursor/src/CursorOutputPlugin.test.ts delete mode 100644 packages/plugin-cursor/src/CursorOutputPlugin.ts delete mode 100644 packages/plugin-cursor/src/index.ts delete mode 100644 packages/plugin-cursor/tsconfig.eslint.json delete mode 100644 packages/plugin-cursor/tsconfig.json delete mode 100644 packages/plugin-cursor/tsconfig.lib.json delete mode 100644 packages/plugin-cursor/tsconfig.test.json delete mode 100644 packages/plugin-cursor/tsdown.config.ts delete mode 100644 packages/plugin-cursor/vite.config.ts delete mode 100644 packages/plugin-cursor/vitest.config.ts delete mode 100644 packages/plugin-droid-cli/eslint.config.ts delete mode 100644 packages/plugin-droid-cli/package.json delete mode 100644 packages/plugin-droid-cli/src/DroidCLIOutputPlugin.test.ts delete mode 100644 packages/plugin-droid-cli/src/DroidCLIOutputPlugin.ts delete mode 100644 packages/plugin-droid-cli/src/index.ts delete mode 100644 packages/plugin-droid-cli/tsconfig.eslint.json delete mode 100644 packages/plugin-droid-cli/tsconfig.json delete mode 100644 packages/plugin-droid-cli/tsconfig.lib.json delete mode 100644 packages/plugin-droid-cli/tsconfig.test.json delete mode 100644 packages/plugin-droid-cli/tsdown.config.ts delete mode 100644 packages/plugin-droid-cli/vite.config.ts delete mode 100644 packages/plugin-droid-cli/vitest.config.ts delete mode 100644 packages/plugin-editorconfig/eslint.config.ts delete mode 100644 packages/plugin-editorconfig/package.json delete mode 100644 packages/plugin-editorconfig/src/EditorConfigOutputPlugin.ts delete mode 100644 packages/plugin-editorconfig/src/index.ts delete mode 100644 packages/plugin-editorconfig/tsconfig.eslint.json delete mode 100644 packages/plugin-editorconfig/tsconfig.json delete mode 100644 packages/plugin-editorconfig/tsconfig.lib.json delete mode 100644 packages/plugin-editorconfig/tsconfig.test.json delete mode 100644 packages/plugin-editorconfig/tsdown.config.ts delete mode 100644 packages/plugin-editorconfig/vite.config.ts delete mode 100644 packages/plugin-editorconfig/vitest.config.ts delete mode 100644 packages/plugin-gemini-cli/eslint.config.ts delete mode 100644 packages/plugin-gemini-cli/package.json delete mode 100644 packages/plugin-gemini-cli/src/GeminiCLIOutputPlugin.ts delete mode 100644 packages/plugin-gemini-cli/src/index.ts delete mode 100644 packages/plugin-gemini-cli/tsconfig.eslint.json delete mode 100644 packages/plugin-gemini-cli/tsconfig.json delete mode 100644 packages/plugin-gemini-cli/tsconfig.lib.json delete mode 100644 packages/plugin-gemini-cli/tsconfig.test.json delete mode 100644 packages/plugin-gemini-cli/tsdown.config.ts delete mode 100644 packages/plugin-gemini-cli/vite.config.ts delete mode 100644 packages/plugin-gemini-cli/vitest.config.ts delete mode 100644 packages/plugin-git-exclude/eslint.config.ts delete mode 100644 packages/plugin-git-exclude/package.json delete mode 100644 packages/plugin-git-exclude/src/GitExcludeOutputPlugin.test.ts delete mode 100644 packages/plugin-git-exclude/src/GitExcludeOutputPlugin.ts delete mode 100644 packages/plugin-git-exclude/src/index.ts delete mode 100644 packages/plugin-git-exclude/tsconfig.eslint.json delete mode 100644 packages/plugin-git-exclude/tsconfig.json delete mode 100644 packages/plugin-git-exclude/tsconfig.lib.json delete mode 100644 packages/plugin-git-exclude/tsconfig.test.json delete mode 100644 packages/plugin-git-exclude/tsdown.config.ts delete mode 100644 packages/plugin-git-exclude/vite.config.ts delete mode 100644 packages/plugin-git-exclude/vitest.config.ts delete mode 100644 packages/plugin-input-agentskills/eslint.config.ts delete mode 100644 packages/plugin-input-agentskills/package.json delete mode 100644 packages/plugin-input-agentskills/src/SkillInputPlugin.test.ts delete mode 100644 packages/plugin-input-agentskills/src/SkillInputPlugin.ts delete mode 100644 packages/plugin-input-agentskills/src/index.ts delete mode 100644 packages/plugin-input-agentskills/tsconfig.eslint.json delete mode 100644 packages/plugin-input-agentskills/tsconfig.json delete mode 100644 packages/plugin-input-agentskills/tsconfig.lib.json delete mode 100644 packages/plugin-input-agentskills/tsconfig.test.json delete mode 100644 packages/plugin-input-agentskills/tsdown.config.ts delete mode 100644 packages/plugin-input-agentskills/vite.config.ts delete mode 100644 packages/plugin-input-agentskills/vitest.config.ts delete mode 100644 packages/plugin-input-editorconfig/eslint.config.ts delete mode 100644 packages/plugin-input-editorconfig/package.json delete mode 100644 packages/plugin-input-editorconfig/src/EditorConfigInputPlugin.ts delete mode 100644 packages/plugin-input-editorconfig/src/index.ts delete mode 100644 packages/plugin-input-editorconfig/tsconfig.eslint.json delete mode 100644 packages/plugin-input-editorconfig/tsconfig.json delete mode 100644 packages/plugin-input-editorconfig/tsconfig.lib.json delete mode 100644 packages/plugin-input-editorconfig/tsconfig.test.json delete mode 100644 packages/plugin-input-editorconfig/tsdown.config.ts delete mode 100644 packages/plugin-input-editorconfig/vite.config.ts delete mode 100644 packages/plugin-input-editorconfig/vitest.config.ts delete mode 100644 packages/plugin-input-fast-command/eslint.config.ts delete mode 100644 packages/plugin-input-fast-command/package.json delete mode 100644 packages/plugin-input-fast-command/src/FastCommandInputPlugin.test.ts delete mode 100644 packages/plugin-input-fast-command/src/FastCommandInputPlugin.ts delete mode 100644 packages/plugin-input-fast-command/src/index.ts delete mode 100644 packages/plugin-input-fast-command/tsconfig.eslint.json delete mode 100644 packages/plugin-input-fast-command/tsconfig.json delete mode 100644 packages/plugin-input-fast-command/tsconfig.lib.json delete mode 100644 packages/plugin-input-fast-command/tsconfig.test.json delete mode 100644 packages/plugin-input-fast-command/tsdown.config.ts delete mode 100644 packages/plugin-input-fast-command/vite.config.ts delete mode 100644 packages/plugin-input-fast-command/vitest.config.ts delete mode 100644 packages/plugin-input-git-exclude/eslint.config.ts delete mode 100644 packages/plugin-input-git-exclude/package.json delete mode 100644 packages/plugin-input-git-exclude/src/GitExcludeInputPlugin.test.ts delete mode 100644 packages/plugin-input-git-exclude/src/GitExcludeInputPlugin.ts delete mode 100644 packages/plugin-input-git-exclude/src/index.ts delete mode 100644 packages/plugin-input-git-exclude/tsconfig.eslint.json delete mode 100644 packages/plugin-input-git-exclude/tsconfig.json delete mode 100644 packages/plugin-input-git-exclude/tsconfig.lib.json delete mode 100644 packages/plugin-input-git-exclude/tsconfig.test.json delete mode 100644 packages/plugin-input-git-exclude/tsdown.config.ts delete mode 100644 packages/plugin-input-git-exclude/vite.config.ts delete mode 100644 packages/plugin-input-git-exclude/vitest.config.ts delete mode 100644 packages/plugin-input-gitignore/eslint.config.ts delete mode 100644 packages/plugin-input-gitignore/package.json delete mode 100644 packages/plugin-input-gitignore/src/GitIgnoreInputPlugin.test.ts delete mode 100644 packages/plugin-input-gitignore/src/GitIgnoreInputPlugin.ts delete mode 100644 packages/plugin-input-gitignore/src/index.ts delete mode 100644 packages/plugin-input-gitignore/tsconfig.eslint.json delete mode 100644 packages/plugin-input-gitignore/tsconfig.json delete mode 100644 packages/plugin-input-gitignore/tsconfig.lib.json delete mode 100644 packages/plugin-input-gitignore/tsconfig.test.json delete mode 100644 packages/plugin-input-gitignore/tsdown.config.ts delete mode 100644 packages/plugin-input-gitignore/vite.config.ts delete mode 100644 packages/plugin-input-gitignore/vitest.config.ts delete mode 100644 packages/plugin-input-global-memory/eslint.config.ts delete mode 100644 packages/plugin-input-global-memory/package.json delete mode 100644 packages/plugin-input-global-memory/src/GlobalMemoryInputPlugin.ts delete mode 100644 packages/plugin-input-global-memory/src/index.ts delete mode 100644 packages/plugin-input-global-memory/tsconfig.eslint.json delete mode 100644 packages/plugin-input-global-memory/tsconfig.json delete mode 100644 packages/plugin-input-global-memory/tsconfig.lib.json delete mode 100644 packages/plugin-input-global-memory/tsconfig.test.json delete mode 100644 packages/plugin-input-global-memory/tsdown.config.ts delete mode 100644 packages/plugin-input-global-memory/vite.config.ts delete mode 100644 packages/plugin-input-global-memory/vitest.config.ts delete mode 100644 packages/plugin-input-jetbrains-config/eslint.config.ts delete mode 100644 packages/plugin-input-jetbrains-config/package.json delete mode 100644 packages/plugin-input-jetbrains-config/src/JetBrainsConfigInputPlugin.ts delete mode 100644 packages/plugin-input-jetbrains-config/src/index.ts delete mode 100644 packages/plugin-input-jetbrains-config/tsconfig.eslint.json delete mode 100644 packages/plugin-input-jetbrains-config/tsconfig.json delete mode 100644 packages/plugin-input-jetbrains-config/tsconfig.lib.json delete mode 100644 packages/plugin-input-jetbrains-config/tsconfig.test.json delete mode 100644 packages/plugin-input-jetbrains-config/tsdown.config.ts delete mode 100644 packages/plugin-input-jetbrains-config/vite.config.ts delete mode 100644 packages/plugin-input-jetbrains-config/vitest.config.ts delete mode 100644 packages/plugin-input-md-cleanup-effect/eslint.config.ts delete mode 100644 packages/plugin-input-md-cleanup-effect/package.json delete mode 100644 packages/plugin-input-md-cleanup-effect/src/MarkdownWhitespaceCleanupEffectInputPlugin.property.test.ts delete mode 100644 packages/plugin-input-md-cleanup-effect/src/MarkdownWhitespaceCleanupEffectInputPlugin.ts delete mode 100644 packages/plugin-input-md-cleanup-effect/src/index.ts delete mode 100644 packages/plugin-input-md-cleanup-effect/tsconfig.eslint.json delete mode 100644 packages/plugin-input-md-cleanup-effect/tsconfig.json delete mode 100644 packages/plugin-input-md-cleanup-effect/tsconfig.lib.json delete mode 100644 packages/plugin-input-md-cleanup-effect/tsconfig.test.json delete mode 100644 packages/plugin-input-md-cleanup-effect/tsdown.config.ts delete mode 100644 packages/plugin-input-md-cleanup-effect/vite.config.ts delete mode 100644 packages/plugin-input-md-cleanup-effect/vitest.config.ts delete mode 100644 packages/plugin-input-orphan-cleanup-effect/eslint.config.ts delete mode 100644 packages/plugin-input-orphan-cleanup-effect/package.json delete mode 100644 packages/plugin-input-orphan-cleanup-effect/src/OrphanFileCleanupEffectInputPlugin.property.test.ts delete mode 100644 packages/plugin-input-orphan-cleanup-effect/src/OrphanFileCleanupEffectInputPlugin.ts delete mode 100644 packages/plugin-input-orphan-cleanup-effect/src/index.ts delete mode 100644 packages/plugin-input-orphan-cleanup-effect/tsconfig.eslint.json delete mode 100644 packages/plugin-input-orphan-cleanup-effect/tsconfig.json delete mode 100644 packages/plugin-input-orphan-cleanup-effect/tsconfig.lib.json delete mode 100644 packages/plugin-input-orphan-cleanup-effect/tsconfig.test.json delete mode 100644 packages/plugin-input-orphan-cleanup-effect/tsdown.config.ts delete mode 100644 packages/plugin-input-orphan-cleanup-effect/vite.config.ts delete mode 100644 packages/plugin-input-orphan-cleanup-effect/vitest.config.ts delete mode 100644 packages/plugin-input-project-prompt/eslint.config.ts delete mode 100644 packages/plugin-input-project-prompt/package.json delete mode 100644 packages/plugin-input-project-prompt/src/ProjectPromptInputPlugin.test.ts delete mode 100644 packages/plugin-input-project-prompt/src/ProjectPromptInputPlugin.ts delete mode 100644 packages/plugin-input-project-prompt/src/index.ts delete mode 100644 packages/plugin-input-project-prompt/tsconfig.eslint.json delete mode 100644 packages/plugin-input-project-prompt/tsconfig.json delete mode 100644 packages/plugin-input-project-prompt/tsconfig.lib.json delete mode 100644 packages/plugin-input-project-prompt/tsconfig.test.json delete mode 100644 packages/plugin-input-project-prompt/tsdown.config.ts delete mode 100644 packages/plugin-input-project-prompt/vite.config.ts delete mode 100644 packages/plugin-input-project-prompt/vitest.config.ts delete mode 100644 packages/plugin-input-readme/eslint.config.ts delete mode 100644 packages/plugin-input-readme/package.json delete mode 100644 packages/plugin-input-readme/src/ReadmeMdInputPlugin.property.test.ts delete mode 100644 packages/plugin-input-readme/src/ReadmeMdInputPlugin.ts delete mode 100644 packages/plugin-input-readme/src/index.ts delete mode 100644 packages/plugin-input-readme/tsconfig.eslint.json delete mode 100644 packages/plugin-input-readme/tsconfig.json delete mode 100644 packages/plugin-input-readme/tsconfig.lib.json delete mode 100644 packages/plugin-input-readme/tsconfig.test.json delete mode 100644 packages/plugin-input-readme/tsdown.config.ts delete mode 100644 packages/plugin-input-readme/vite.config.ts delete mode 100644 packages/plugin-input-readme/vitest.config.ts delete mode 100644 packages/plugin-input-rule/eslint.config.ts delete mode 100644 packages/plugin-input-rule/package.json delete mode 100644 packages/plugin-input-rule/src/RuleInputPlugin.test.ts delete mode 100644 packages/plugin-input-rule/src/RuleInputPlugin.ts delete mode 100644 packages/plugin-input-rule/src/index.ts delete mode 100644 packages/plugin-input-rule/tsconfig.eslint.json delete mode 100644 packages/plugin-input-rule/tsconfig.json delete mode 100644 packages/plugin-input-rule/tsconfig.lib.json delete mode 100644 packages/plugin-input-rule/tsconfig.test.json delete mode 100644 packages/plugin-input-rule/tsdown.config.ts delete mode 100644 packages/plugin-input-rule/vite.config.ts delete mode 100644 packages/plugin-input-rule/vitest.config.ts delete mode 100644 packages/plugin-input-shadow-project/eslint.config.ts delete mode 100644 packages/plugin-input-shadow-project/package.json delete mode 100644 packages/plugin-input-shadow-project/src/ShadowProjectInputPlugin.test.ts delete mode 100644 packages/plugin-input-shadow-project/src/ShadowProjectInputPlugin.ts delete mode 100644 packages/plugin-input-shadow-project/src/index.ts delete mode 100644 packages/plugin-input-shadow-project/tsconfig.eslint.json delete mode 100644 packages/plugin-input-shadow-project/tsconfig.json delete mode 100644 packages/plugin-input-shadow-project/tsconfig.lib.json delete mode 100644 packages/plugin-input-shadow-project/tsconfig.test.json delete mode 100644 packages/plugin-input-shadow-project/tsdown.config.ts delete mode 100644 packages/plugin-input-shadow-project/vite.config.ts delete mode 100644 packages/plugin-input-shadow-project/vitest.config.ts delete mode 100644 packages/plugin-input-shared-ignore/eslint.config.ts delete mode 100644 packages/plugin-input-shared-ignore/package.json delete mode 100644 packages/plugin-input-shared-ignore/src/AIAgentIgnoreInputPlugin.ts delete mode 100644 packages/plugin-input-shared-ignore/src/index.ts delete mode 100644 packages/plugin-input-shared-ignore/tsconfig.json delete mode 100644 packages/plugin-input-shared-ignore/tsconfig.lib.json delete mode 100644 packages/plugin-input-shared-ignore/tsdown.config.ts delete mode 100644 packages/plugin-input-shared/eslint.config.ts delete mode 100644 packages/plugin-input-shared/package.json delete mode 100644 packages/plugin-input-shared/src/AbstractInputPlugin.test.ts delete mode 100644 packages/plugin-input-shared/src/AbstractInputPlugin.ts delete mode 100644 packages/plugin-input-shared/src/BaseDirectoryInputPlugin.ts delete mode 100644 packages/plugin-input-shared/src/BaseFileInputPlugin.ts delete mode 100644 packages/plugin-input-shared/src/index.ts delete mode 100644 packages/plugin-input-shared/src/scope/GlobalScopeCollector.ts delete mode 100644 packages/plugin-input-shared/src/scope/ScopeRegistry.ts delete mode 100644 packages/plugin-input-shared/src/scope/index.ts delete mode 100644 packages/plugin-input-shared/tsconfig.eslint.json delete mode 100644 packages/plugin-input-shared/tsconfig.json delete mode 100644 packages/plugin-input-shared/tsconfig.lib.json delete mode 100644 packages/plugin-input-shared/tsconfig.test.json delete mode 100644 packages/plugin-input-shared/tsdown.config.ts delete mode 100644 packages/plugin-input-shared/vite.config.ts delete mode 100644 packages/plugin-input-shared/vitest.config.ts delete mode 100644 packages/plugin-input-skill-sync-effect/eslint.config.ts delete mode 100644 packages/plugin-input-skill-sync-effect/package.json delete mode 100644 packages/plugin-input-skill-sync-effect/src/SkillNonSrcFileSyncEffectInputPlugin.property.test.ts delete mode 100644 packages/plugin-input-skill-sync-effect/src/SkillNonSrcFileSyncEffectInputPlugin.ts delete mode 100644 packages/plugin-input-skill-sync-effect/src/index.ts delete mode 100644 packages/plugin-input-skill-sync-effect/tsconfig.eslint.json delete mode 100644 packages/plugin-input-skill-sync-effect/tsconfig.json delete mode 100644 packages/plugin-input-skill-sync-effect/tsconfig.lib.json delete mode 100644 packages/plugin-input-skill-sync-effect/tsconfig.test.json delete mode 100644 packages/plugin-input-skill-sync-effect/tsdown.config.ts delete mode 100644 packages/plugin-input-skill-sync-effect/vite.config.ts delete mode 100644 packages/plugin-input-skill-sync-effect/vitest.config.ts delete mode 100644 packages/plugin-input-subagent/eslint.config.ts delete mode 100644 packages/plugin-input-subagent/package.json delete mode 100644 packages/plugin-input-subagent/src/SubAgentInputPlugin.test.ts delete mode 100644 packages/plugin-input-subagent/src/SubAgentInputPlugin.ts delete mode 100644 packages/plugin-input-subagent/src/index.ts delete mode 100644 packages/plugin-input-subagent/tsconfig.eslint.json delete mode 100644 packages/plugin-input-subagent/tsconfig.json delete mode 100644 packages/plugin-input-subagent/tsconfig.lib.json delete mode 100644 packages/plugin-input-subagent/tsconfig.test.json delete mode 100644 packages/plugin-input-subagent/tsdown.config.ts delete mode 100644 packages/plugin-input-subagent/vite.config.ts delete mode 100644 packages/plugin-input-subagent/vitest.config.ts delete mode 100644 packages/plugin-input-vscode-config/eslint.config.ts delete mode 100644 packages/plugin-input-vscode-config/package.json delete mode 100644 packages/plugin-input-vscode-config/src/VSCodeConfigInputPlugin.ts delete mode 100644 packages/plugin-input-vscode-config/src/index.ts delete mode 100644 packages/plugin-input-vscode-config/tsconfig.eslint.json delete mode 100644 packages/plugin-input-vscode-config/tsconfig.json delete mode 100644 packages/plugin-input-vscode-config/tsconfig.lib.json delete mode 100644 packages/plugin-input-vscode-config/tsconfig.test.json delete mode 100644 packages/plugin-input-vscode-config/tsdown.config.ts delete mode 100644 packages/plugin-input-vscode-config/vite.config.ts delete mode 100644 packages/plugin-input-vscode-config/vitest.config.ts delete mode 100644 packages/plugin-input-workspace/eslint.config.ts delete mode 100644 packages/plugin-input-workspace/package.json delete mode 100644 packages/plugin-input-workspace/src/WorkspaceInputPlugin.ts delete mode 100644 packages/plugin-input-workspace/src/index.ts delete mode 100644 packages/plugin-input-workspace/tsconfig.eslint.json delete mode 100644 packages/plugin-input-workspace/tsconfig.json delete mode 100644 packages/plugin-input-workspace/tsconfig.lib.json delete mode 100644 packages/plugin-input-workspace/tsconfig.test.json delete mode 100644 packages/plugin-input-workspace/tsdown.config.ts delete mode 100644 packages/plugin-input-workspace/vite.config.ts delete mode 100644 packages/plugin-input-workspace/vitest.config.ts delete mode 100644 packages/plugin-jetbrains-ai-codex/eslint.config.ts delete mode 100644 packages/plugin-jetbrains-ai-codex/package.json delete mode 100644 packages/plugin-jetbrains-ai-codex/src/JetBrainsAIAssistantCodexOutputPlugin.test.ts delete mode 100644 packages/plugin-jetbrains-ai-codex/src/JetBrainsAIAssistantCodexOutputPlugin.ts delete mode 100644 packages/plugin-jetbrains-ai-codex/src/index.ts delete mode 100644 packages/plugin-jetbrains-ai-codex/tsconfig.eslint.json delete mode 100644 packages/plugin-jetbrains-ai-codex/tsconfig.json delete mode 100644 packages/plugin-jetbrains-ai-codex/tsconfig.lib.json delete mode 100644 packages/plugin-jetbrains-ai-codex/tsconfig.test.json delete mode 100644 packages/plugin-jetbrains-ai-codex/tsdown.config.ts delete mode 100644 packages/plugin-jetbrains-ai-codex/vite.config.ts delete mode 100644 packages/plugin-jetbrains-ai-codex/vitest.config.ts delete mode 100644 packages/plugin-jetbrains-codestyle/eslint.config.ts delete mode 100644 packages/plugin-jetbrains-codestyle/package.json delete mode 100644 packages/plugin-jetbrains-codestyle/src/JetBrainsIDECodeStyleConfigOutputPlugin.ts delete mode 100644 packages/plugin-jetbrains-codestyle/src/index.ts delete mode 100644 packages/plugin-jetbrains-codestyle/tsconfig.eslint.json delete mode 100644 packages/plugin-jetbrains-codestyle/tsconfig.json delete mode 100644 packages/plugin-jetbrains-codestyle/tsconfig.lib.json delete mode 100644 packages/plugin-jetbrains-codestyle/tsconfig.test.json delete mode 100644 packages/plugin-jetbrains-codestyle/tsdown.config.ts delete mode 100644 packages/plugin-jetbrains-codestyle/vite.config.ts delete mode 100644 packages/plugin-jetbrains-codestyle/vitest.config.ts delete mode 100644 packages/plugin-kiro-ide/env.d.ts delete mode 100644 packages/plugin-kiro-ide/eslint.config.ts delete mode 100644 packages/plugin-kiro-ide/package.json delete mode 100644 packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.projectConfig.test.ts delete mode 100644 packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.test.ts delete mode 100644 packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.ts delete mode 100644 packages/plugin-kiro-ide/src/KiroPowers.integration.test.ts delete mode 100644 packages/plugin-kiro-ide/src/KiroPowersRegistryWriter.property.test.ts delete mode 100644 packages/plugin-kiro-ide/src/KiroPowersRegistryWriter.ts delete mode 100644 packages/plugin-kiro-ide/src/index.ts delete mode 100644 packages/plugin-kiro-ide/tsconfig.eslint.json delete mode 100644 packages/plugin-kiro-ide/tsconfig.json delete mode 100644 packages/plugin-kiro-ide/tsconfig.lib.json delete mode 100644 packages/plugin-kiro-ide/tsconfig.test.json delete mode 100644 packages/plugin-kiro-ide/tsdown.config.ts delete mode 100644 packages/plugin-kiro-ide/vite.config.ts delete mode 100644 packages/plugin-kiro-ide/vitest.config.ts delete mode 100644 packages/plugin-openai-codex-cli/eslint.config.ts delete mode 100644 packages/plugin-openai-codex-cli/package.json delete mode 100644 packages/plugin-openai-codex-cli/src/CodexCLIOutputPlugin.ts delete mode 100644 packages/plugin-openai-codex-cli/src/index.ts delete mode 100644 packages/plugin-openai-codex-cli/tsconfig.eslint.json delete mode 100644 packages/plugin-openai-codex-cli/tsconfig.json delete mode 100644 packages/plugin-openai-codex-cli/tsconfig.lib.json delete mode 100644 packages/plugin-openai-codex-cli/tsconfig.test.json delete mode 100644 packages/plugin-openai-codex-cli/tsdown.config.ts delete mode 100644 packages/plugin-openai-codex-cli/vite.config.ts delete mode 100644 packages/plugin-openai-codex-cli/vitest.config.ts delete mode 100644 packages/plugin-opencode-cli/eslint.config.ts delete mode 100644 packages/plugin-opencode-cli/package.json delete mode 100644 packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.projectConfig.test.ts delete mode 100644 packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.property.test.ts delete mode 100644 packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.test.ts delete mode 100644 packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.ts delete mode 100644 packages/plugin-opencode-cli/src/index.ts delete mode 100644 packages/plugin-opencode-cli/tsconfig.eslint.json delete mode 100644 packages/plugin-opencode-cli/tsconfig.json delete mode 100644 packages/plugin-opencode-cli/tsconfig.lib.json delete mode 100644 packages/plugin-opencode-cli/tsconfig.test.json delete mode 100644 packages/plugin-opencode-cli/tsdown.config.ts delete mode 100644 packages/plugin-opencode-cli/vite.config.ts delete mode 100644 packages/plugin-opencode-cli/vitest.config.ts delete mode 100644 packages/plugin-output-shared/eslint.config.ts delete mode 100644 packages/plugin-output-shared/package.json delete mode 100644 packages/plugin-output-shared/src/AbstractOutputPlugin.test.ts delete mode 100644 packages/plugin-output-shared/src/AbstractOutputPlugin.ts delete mode 100644 packages/plugin-output-shared/src/BaseCLIOutputPlugin.ts delete mode 100644 packages/plugin-output-shared/src/index.ts delete mode 100644 packages/plugin-output-shared/src/registry/RegistryWriter.ts delete mode 100644 packages/plugin-output-shared/src/registry/index.ts delete mode 100644 packages/plugin-output-shared/src/utils/commandFilter.ts delete mode 100644 packages/plugin-output-shared/src/utils/gitUtils.ts delete mode 100644 packages/plugin-output-shared/src/utils/index.ts delete mode 100644 packages/plugin-output-shared/src/utils/pathNormalization.property.test.ts delete mode 100644 packages/plugin-output-shared/src/utils/ruleFilter.ts delete mode 100644 packages/plugin-output-shared/src/utils/seriesFilter.napi-equivalence.property.test.ts delete mode 100644 packages/plugin-output-shared/src/utils/seriesFilter.property.test.ts delete mode 100644 packages/plugin-output-shared/src/utils/seriesFilter.ts delete mode 100644 packages/plugin-output-shared/src/utils/skillFilter.ts delete mode 100644 packages/plugin-output-shared/src/utils/subAgentFilter.ts delete mode 100644 packages/plugin-output-shared/src/utils/subSeriesGlobExpansion.property.test.ts delete mode 100644 packages/plugin-output-shared/src/utils/typeSpecificFilters.property.test.ts delete mode 100644 packages/plugin-output-shared/tsconfig.eslint.json delete mode 100644 packages/plugin-output-shared/tsconfig.json delete mode 100644 packages/plugin-output-shared/tsconfig.lib.json delete mode 100644 packages/plugin-output-shared/tsconfig.test.json delete mode 100644 packages/plugin-output-shared/tsdown.config.ts delete mode 100644 packages/plugin-output-shared/vite.config.ts delete mode 100644 packages/plugin-output-shared/vitest.config.ts delete mode 100644 packages/plugin-qoder-ide/eslint.config.ts delete mode 100644 packages/plugin-qoder-ide/package.json delete mode 100644 packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.frontmatter.test.ts delete mode 100644 packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.projectConfig.test.ts delete mode 100644 packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.test.ts delete mode 100644 packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.ts delete mode 100644 packages/plugin-qoder-ide/src/index.ts delete mode 100644 packages/plugin-qoder-ide/tsconfig.eslint.json delete mode 100644 packages/plugin-qoder-ide/tsconfig.json delete mode 100644 packages/plugin-qoder-ide/tsconfig.lib.json delete mode 100644 packages/plugin-qoder-ide/tsconfig.test.json delete mode 100644 packages/plugin-qoder-ide/tsdown.config.ts delete mode 100644 packages/plugin-qoder-ide/vite.config.ts delete mode 100644 packages/plugin-qoder-ide/vitest.config.ts delete mode 100644 packages/plugin-readme/eslint.config.ts delete mode 100644 packages/plugin-readme/package.json delete mode 100644 packages/plugin-readme/src/ReadmeMdConfigFileOutputPlugin.property.test.ts delete mode 100644 packages/plugin-readme/src/ReadmeMdConfigFileOutputPlugin.ts delete mode 100644 packages/plugin-readme/src/index.ts delete mode 100644 packages/plugin-readme/tsconfig.eslint.json delete mode 100644 packages/plugin-readme/tsconfig.json delete mode 100644 packages/plugin-readme/tsconfig.lib.json delete mode 100644 packages/plugin-readme/tsconfig.test.json delete mode 100644 packages/plugin-readme/tsdown.config.ts delete mode 100644 packages/plugin-readme/vite.config.ts delete mode 100644 packages/plugin-readme/vitest.config.ts delete mode 100644 packages/plugin-shared/eslint.config.ts delete mode 100644 packages/plugin-shared/package.json delete mode 100644 packages/plugin-shared/src/AbstractPlugin.ts delete mode 100644 packages/plugin-shared/src/PluginNames.ts delete mode 100644 packages/plugin-shared/src/constants.ts delete mode 100644 packages/plugin-shared/src/index.ts delete mode 100644 packages/plugin-shared/src/log.ts delete mode 100644 packages/plugin-shared/src/testing/index.ts delete mode 100644 packages/plugin-shared/src/types/ConfigTypes.schema.property.test.ts delete mode 100644 packages/plugin-shared/src/types/ConfigTypes.schema.ts delete mode 100644 packages/plugin-shared/src/types/Enums.ts delete mode 100644 packages/plugin-shared/src/types/Errors.ts delete mode 100644 packages/plugin-shared/src/types/ExportMetadataTypes.ts delete mode 100644 packages/plugin-shared/src/types/FileSystemTypes.ts delete mode 100644 packages/plugin-shared/src/types/InputTypes.ts delete mode 100644 packages/plugin-shared/src/types/OutputTypes.ts delete mode 100644 packages/plugin-shared/src/types/PluginTypes.ts delete mode 100644 packages/plugin-shared/src/types/PromptTypes.ts delete mode 100644 packages/plugin-shared/src/types/RegistryTypes.ts delete mode 100644 packages/plugin-shared/src/types/ShadowSourceProjectTypes.ts delete mode 100644 packages/plugin-shared/src/types/index.ts delete mode 100644 packages/plugin-shared/src/types/seriNamePropagation.property.test.ts delete mode 100644 packages/plugin-shared/tsconfig.eslint.json delete mode 100644 packages/plugin-shared/tsconfig.json delete mode 100644 packages/plugin-shared/tsconfig.lib.json delete mode 100644 packages/plugin-shared/tsconfig.test.json delete mode 100644 packages/plugin-shared/tsdown.config.ts delete mode 100644 packages/plugin-shared/vite.config.ts delete mode 100644 packages/plugin-shared/vitest.config.ts delete mode 100644 packages/plugin-trae-ide/eslint.config.ts delete mode 100644 packages/plugin-trae-ide/package.json delete mode 100644 packages/plugin-trae-ide/src/TraeIDEOutputPlugin.test.ts delete mode 100644 packages/plugin-trae-ide/src/TraeIDEOutputPlugin.ts delete mode 100644 packages/plugin-trae-ide/src/index.ts delete mode 100644 packages/plugin-trae-ide/tsconfig.eslint.json delete mode 100644 packages/plugin-trae-ide/tsconfig.json delete mode 100644 packages/plugin-trae-ide/tsconfig.lib.json delete mode 100644 packages/plugin-trae-ide/tsconfig.test.json delete mode 100644 packages/plugin-trae-ide/tsdown.config.ts delete mode 100644 packages/plugin-trae-ide/vite.config.ts delete mode 100644 packages/plugin-trae-ide/vitest.config.ts delete mode 100644 packages/plugin-vscode/eslint.config.ts delete mode 100644 packages/plugin-vscode/package.json delete mode 100644 packages/plugin-vscode/src/VisualStudioCodeIDEConfigOutputPlugin.ts delete mode 100644 packages/plugin-vscode/src/index.ts delete mode 100644 packages/plugin-vscode/tsconfig.eslint.json delete mode 100644 packages/plugin-vscode/tsconfig.json delete mode 100644 packages/plugin-vscode/tsconfig.lib.json delete mode 100644 packages/plugin-vscode/tsconfig.test.json delete mode 100644 packages/plugin-vscode/tsdown.config.ts delete mode 100644 packages/plugin-vscode/vite.config.ts delete mode 100644 packages/plugin-vscode/vitest.config.ts delete mode 100644 packages/plugin-warp-ide/eslint.config.ts delete mode 100644 packages/plugin-warp-ide/package.json delete mode 100644 packages/plugin-warp-ide/src/WarpIDEOutputPlugin.test.ts delete mode 100644 packages/plugin-warp-ide/src/WarpIDEOutputPlugin.ts delete mode 100644 packages/plugin-warp-ide/src/index.ts delete mode 100644 packages/plugin-warp-ide/tsconfig.eslint.json delete mode 100644 packages/plugin-warp-ide/tsconfig.json delete mode 100644 packages/plugin-warp-ide/tsconfig.lib.json delete mode 100644 packages/plugin-warp-ide/tsconfig.test.json delete mode 100644 packages/plugin-warp-ide/tsdown.config.ts delete mode 100644 packages/plugin-warp-ide/vite.config.ts delete mode 100644 packages/plugin-warp-ide/vitest.config.ts delete mode 100644 packages/plugin-windsurf/eslint.config.ts delete mode 100644 packages/plugin-windsurf/package.json delete mode 100644 packages/plugin-windsurf/src/WindsurfOutputPlugin.projectConfig.test.ts delete mode 100644 packages/plugin-windsurf/src/WindsurfOutputPlugin.property.test.ts delete mode 100644 packages/plugin-windsurf/src/WindsurfOutputPlugin.test.ts delete mode 100644 packages/plugin-windsurf/src/WindsurfOutputPlugin.ts delete mode 100644 packages/plugin-windsurf/src/index.ts delete mode 100644 packages/plugin-windsurf/tsconfig.eslint.json delete mode 100644 packages/plugin-windsurf/tsconfig.json delete mode 100644 packages/plugin-windsurf/tsconfig.lib.json delete mode 100644 packages/plugin-windsurf/tsconfig.test.json delete mode 100644 packages/plugin-windsurf/tsdown.config.ts delete mode 100644 packages/plugin-windsurf/vite.config.ts delete mode 100644 packages/plugin-windsurf/vitest.config.ts diff --git a/cli/src/commands/CleanupUtils.ts b/cli/src/commands/CleanupUtils.ts index 4d4a6d09..d504440f 100644 --- a/cli/src/commands/CleanupUtils.ts +++ b/cli/src/commands/CleanupUtils.ts @@ -1,7 +1,7 @@ -import type {ILogger, OutputCleanContext, OutputPlugin} from '@truenine/plugin-shared' +import type {ILogger, OutputCleanContext, OutputPlugin} from '../plugins/plugin-shared' import * as path from 'node:path' -import {deleteDirectories as deskDeleteDirectories, deleteFiles as deskDeleteFiles} from '@truenine/desk-paths' -import {checkCanClean, collectAllPluginOutputs, executeOnCleanComplete} from '@truenine/plugin-shared' +import {deleteDirectories as deskDeleteDirectories, deleteFiles as deskDeleteFiles} from '../plugins/desk-paths' +import {checkCanClean, collectAllPluginOutputs, executeOnCleanComplete} from '../plugins/plugin-shared' /** * Result of cleanup operation diff --git a/cli/src/commands/Command.ts b/cli/src/commands/Command.ts index 5291365d..4f8b14b3 100644 --- a/cli/src/commands/Command.ts +++ b/cli/src/commands/Command.ts @@ -1,4 +1,4 @@ -import type {CollectedInputContext, ILogger, OutputCleanContext, OutputPlugin, OutputWriteContext, PluginOptions, UserConfigFile} from '@truenine/plugin-shared' +import type {CollectedInputContext, ILogger, OutputCleanContext, OutputPlugin, OutputWriteContext, PluginOptions, UserConfigFile} from '../plugins/plugin-shared' /** * Command execution context diff --git a/cli/src/commands/CommandUtils.ts b/cli/src/commands/CommandUtils.ts index e76942cf..17ca3adf 100644 --- a/cli/src/commands/CommandUtils.ts +++ b/cli/src/commands/CommandUtils.ts @@ -1,5 +1,5 @@ -import type {OutputPlugin, OutputWriteContext} from '@truenine/plugin-shared' -import {checkCanWrite} from '@truenine/plugin-shared' +import type {OutputPlugin, OutputWriteContext} from '../plugins/plugin-shared' +import {checkCanWrite} from '../plugins/plugin-shared' /** * Filter plugins based on write permissions. diff --git a/cli/src/commands/DryRunCleanCommand.ts b/cli/src/commands/DryRunCleanCommand.ts index 758e78a4..4d3b105a 100644 --- a/cli/src/commands/DryRunCleanCommand.ts +++ b/cli/src/commands/DryRunCleanCommand.ts @@ -1,6 +1,6 @@ import type {Command, CommandContext, CommandResult} from './Command' import * as path from 'node:path' -import {checkCanClean, collectAllPluginOutputs, executeOnCleanComplete} from '@truenine/plugin-shared' +import {checkCanClean, collectAllPluginOutputs, executeOnCleanComplete} from '../plugins/plugin-shared' import {collectDeletionTargets} from './CleanupUtils' /** diff --git a/cli/src/commands/DryRunOutputCommand.ts b/cli/src/commands/DryRunOutputCommand.ts index 72a212eb..f90da009 100644 --- a/cli/src/commands/DryRunOutputCommand.ts +++ b/cli/src/commands/DryRunOutputCommand.ts @@ -1,5 +1,5 @@ import type {Command, CommandContext, CommandResult} from './Command' -import {checkCanWrite, executeWriteOutputs} from '@truenine/plugin-shared' +import {checkCanWrite, executeWriteOutputs} from '../plugins/plugin-shared' /** * Dry-run output command - simulates write operations without actual I/O diff --git a/cli/src/commands/ExecuteCommand.ts b/cli/src/commands/ExecuteCommand.ts index 222907fb..dcdad900 100644 --- a/cli/src/commands/ExecuteCommand.ts +++ b/cli/src/commands/ExecuteCommand.ts @@ -1,5 +1,5 @@ import type {Command, CommandContext, CommandResult} from './Command' -import {checkCanWrite, executeWriteOutputs} from '@truenine/plugin-shared' +import {checkCanWrite, executeWriteOutputs} from '../plugins/plugin-shared' import {performCleanup} from './CleanupUtils' /** diff --git a/cli/src/commands/PluginsCommand.ts b/cli/src/commands/PluginsCommand.ts index ca355571..8a040cb6 100644 --- a/cli/src/commands/PluginsCommand.ts +++ b/cli/src/commands/PluginsCommand.ts @@ -1,6 +1,6 @@ import type {Command, CommandContext, CommandResult, JsonPluginInfo} from './Command' import process from 'node:process' -import {PluginKind} from '@truenine/plugin-shared' +import {PluginKind} from '../plugins/plugin-shared' /** * Command that outputs all registered plugin information as JSON. diff --git a/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts index 6bb0ddc5..08bf5b0f 100644 --- a/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts +++ b/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts @@ -1,6 +1,6 @@ /** Property 6: Type-specific filters use correct config sections. Validates: Requirements 7.1, 7.2, 7.3, 7.4 */ -import type {FastCommandPrompt, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' -import type {ProjectConfig} from '@truenine/plugin-shared/types' +import type {FastCommandPrompt, RulePrompt, SkillPrompt, SubAgentPrompt} from '../../../plugin-shared' +import type {ProjectConfig} from '../../../plugin-shared/types' import * as fc from 'fast-check' import {describe, expect, it} from 'vitest' diff --git a/cli/src/utils/RelativePathFactory.ts b/cli/src/utils/RelativePathFactory.ts index 3e45a8bf..29d6aae5 100644 --- a/cli/src/utils/RelativePathFactory.ts +++ b/cli/src/utils/RelativePathFactory.ts @@ -1,6 +1,6 @@ -import type {RelativePath} from '@truenine/plugin-shared' +import type {RelativePath} from '../plugins/plugin-shared' import * as path from 'node:path' -import {FilePathKind} from '@truenine/plugin-shared' +import {FilePathKind} from '../plugins/plugin-shared' /** * Options for creating a RelativePath diff --git a/cli/src/utils/ResourceUtils.ts b/cli/src/utils/ResourceUtils.ts index 61676088..94677a01 100644 --- a/cli/src/utils/ResourceUtils.ts +++ b/cli/src/utils/ResourceUtils.ts @@ -1,5 +1,5 @@ -import type {SkillResourceCategory} from '@truenine/plugin-shared' -import {SKILL_RESOURCE_BINARY_EXTENSIONS} from '@truenine/plugin-shared' +import type {SkillResourceCategory} from '../plugins/plugin-shared' +import {SKILL_RESOURCE_BINARY_EXTENSIONS} from '../plugins/plugin-shared' /** * Check if a file extension is a binary resource extension. diff --git a/cli/src/utils/WriteHelper.ts b/cli/src/utils/WriteHelper.ts index 085fe43b..61e53916 100644 --- a/cli/src/utils/WriteHelper.ts +++ b/cli/src/utils/WriteHelper.ts @@ -1,13 +1,13 @@ -import type {RelativePath} from '@truenine/plugin-shared' +import type {RelativePath} from '../plugins/plugin-shared' import * as path from 'node:path' -import {createRelativePath} from '@truenine/desk-paths' +import {createRelativePath} from '../plugins/desk-paths' export { type SafeWriteOptions, type SafeWriteResult, writeFileSafe, type WriteLogger -} from '@truenine/desk-paths' // Re-export from desk-paths +} from '../plugins/desk-paths' // Re-export from desk-paths /** * Options for creating a RelativePath for output files diff --git a/cli/src/utils/ruleFilter.ts b/cli/src/utils/ruleFilter.ts index 27cfac91..cdaa2ea4 100644 --- a/cli/src/utils/ruleFilter.ts +++ b/cli/src/utils/ruleFilter.ts @@ -1,5 +1,5 @@ -import type {ProjectConfig, RulePrompt} from '@truenine/plugin-shared' -import {matchesSeries, resolveEffectiveIncludeSeries, resolveSubSeries} from '@truenine/plugin-output-shared' +import type {ProjectConfig, RulePrompt} from '../plugins/plugin-shared' +import {matchesSeries, resolveEffectiveIncludeSeries, resolveSubSeries} from '../plugins/plugin-output-shared' function normalizeSubdirPath(subdir: string): string { let normalized = subdir.replaceAll(/\.\/+/g, '') diff --git a/packages/desk-paths/eslint.config.ts b/packages/desk-paths/eslint.config.ts deleted file mode 100644 index 13a6c4cf..00000000 --- a/packages/desk-paths/eslint.config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' - -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: { - allowDefaultProject: true - } - }, - ignores: [ - '.turbo/**', - 'aindex/**', - '*.md', - '**/*.md', - '.kiro/**', - '.claude/**', - '.factory/**', - 'src/AGENTS.md', - 'public/**', - '.skills/**', - '**/.skills/**', - '.agent/**' - ] -}) - -export default config as unknown diff --git a/packages/desk-paths/package.json b/packages/desk-paths/package.json deleted file mode 100644 index ba611149..00000000 --- a/packages/desk-paths/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@truenine/desk-paths", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": {} -} diff --git a/packages/desk-paths/src/index.property.test.ts b/packages/desk-paths/src/index.property.test.ts deleted file mode 100644 index da935029..00000000 --- a/packages/desk-paths/src/index.property.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type {WriteLogger} from './index' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import * as fc from 'fast-check' -import {afterEach, beforeEach, describe, expect, it} from 'vitest' -import { - createFileRelativePath, - createRelativePath, - deleteDirectories, - deleteFiles, - ensureDir, - FilePathKind, - readFileSync, - writeFileSafe, - writeFileSync - -} from './index' - -let tmpDir: string - -beforeEach(() => tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'desk-paths-test-'))) - -afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true})) - -/** Generate safe relative path segments (no special chars, no empty) */ -const alphaNum = 'abcdefghijklmnopqrstuvwxyz0123456789' -const safeSegment = fc.array(fc.constantFrom(...alphaNum), {minLength: 1, maxLength: 8}).map(chars => chars.join('')) -const safePath = fc.array(safeSegment, {minLength: 1, maxLength: 4}).map(segs => segs.join('/')) - -describe('ensureDir', () => { // Property 1: ensureDir idempotence - it('property: calling ensureDir multiple times is idempotent', () => { - fc.assert(fc.property(safePath, relPath => { - const dir = path.join(tmpDir, relPath) - ensureDir(dir) - expect(fs.existsSync(dir)).toBe(true) - expect(fs.statSync(dir).isDirectory()).toBe(true) - - ensureDir(dir) // Second call should not throw and dir still exists - expect(fs.existsSync(dir)).toBe(true) - expect(fs.statSync(dir).isDirectory()).toBe(true) - }), {numRuns: 30}) - }) -}) - -describe('writeFileSync / readFileSync', () => { // Property 2: writeFileSync/readFileSync round-trip - it('property: round-trip preserves content', () => { - fc.assert(fc.property(safeSegment, fc.string({minLength: 0, maxLength: 500}), (name, content) => { - const filePath = path.join(tmpDir, `${name}.txt`) - writeFileSync(filePath, content) - const read = readFileSync(filePath) - expect(read).toBe(content) - }), {numRuns: 30}) - }) - - it('property: writeFileSync auto-creates parent directories', () => { - fc.assert(fc.property(safePath, safeSegment, (relDir, name) => { - const filePath = path.join(tmpDir, relDir, `${name}.txt`) - writeFileSync(filePath, 'test') - expect(fs.existsSync(filePath)).toBe(true) - }), {numRuns: 20}) - }) - - it('readFileSync throws with path context on missing file', () => { - const missing = path.join(tmpDir, 'nonexistent.txt') - expect(() => readFileSync(missing)).toThrow(missing) - }) -}) - -describe('deleteFiles', () => { // Property 3: deleteFiles removes all existing files - it('property: deletes all existing files and skips non-existent', () => { - fc.assert(fc.property( - fc.array(safeSegment, {minLength: 1, maxLength: 5}), - names => { - const uniqueNames = [...new Set(names)] - const existingFiles = uniqueNames.map(n => { - const p = path.join(tmpDir, `${n}.txt`) - fs.writeFileSync(p, 'data') - return p - }) - const nonExistent = path.join(tmpDir, 'ghost.txt') - const allFiles = [...existingFiles, nonExistent] - - const result = deleteFiles(allFiles) - expect(result.deleted).toBe(existingFiles.length) - expect(result.errors).toHaveLength(0) - - for (const f of existingFiles) expect(fs.existsSync(f)).toBe(false) - } - ), {numRuns: 20}) - }) -}) - -describe('deleteDirectories', () => { // Property 4: deleteDirectories removes all directories regardless of input order - it('property: removes nested directories in correct order', () => { - fc.assert(fc.property( - fc.array(safeSegment, {minLength: 2, maxLength: 4}), - segments => { - const dirs: string[] = [] // Create nested directory structure - for (let i = 1; i <= segments.length; i++) { - const dir = path.join(tmpDir, ...segments.slice(0, i)) - fs.mkdirSync(dir, {recursive: true}) - dirs.push(dir) - } - - const shuffled = [...dirs].sort(() => Math.random() - 0.5) // Shuffle to test order independence - const result = deleteDirectories(shuffled) - - expect(result.errors).toHaveLength(0) - for (const d of dirs) { // At least the deepest should be deleted; parents may already be gone - expect(fs.existsSync(d)).toBe(false) - } - } - ), {numRuns: 20}) - }) -}) - -describe('createRelativePath', () => { // Property 5: createRelativePath construction correctness - it('property: pathKind is always Relative, path and basePath match inputs', () => { - fc.assert(fc.property(safePath, safePath, (pathStr, basePath) => { - const rp = createRelativePath(pathStr, basePath, () => 'dir') - expect(rp.pathKind).toBe(FilePathKind.Relative) - expect(rp.path).toBe(pathStr) - expect(rp.basePath).toBe(basePath) - expect(rp.getDirectoryName()).toBe('dir') - expect(rp.getAbsolutePath()).toBe(path.join(basePath, pathStr)) - }), {numRuns: 30}) - }) -}) - -describe('createFileRelativePath', () => { // Property 6: createFileRelativePath construction correctness - it('property: file path is parent path joined with filename', () => { - fc.assert(fc.property(safePath, safePath, safeSegment, (dirPath, basePath, fileName) => { - const parent = createRelativePath(dirPath, basePath, () => 'parentDir') - const file = createFileRelativePath(parent, fileName) - - expect(file.pathKind).toBe(FilePathKind.Relative) - expect(file.path).toBe(path.join(dirPath, fileName)) - expect(file.basePath).toBe(basePath) - expect(file.getDirectoryName()).toBe('parentDir') - expect(file.getAbsolutePath()).toBe(path.join(basePath, dirPath, fileName)) - }), {numRuns: 30}) - }) -}) - -describe('writeFileSafe', () => { // Property for writeFileSafe - const noopLogger: WriteLogger = { - trace: () => {}, - error: () => {} - } - - it('property: dry-run never creates files', () => { - fc.assert(fc.property(safeSegment, fc.string({minLength: 1, maxLength: 100}), (name, content) => { - const fullPath = path.join(tmpDir, 'dryrun', `${name}.txt`) - const rp = createRelativePath(`${name}.txt`, path.join(tmpDir, 'dryrun'), () => 'dryrun') - - const result = writeFileSafe({fullPath, content, type: 'test', relativePath: rp, dryRun: true, logger: noopLogger}) - expect(result.success).toBe(true) - expect(result.skipped).toBe(false) - expect(fs.existsSync(fullPath)).toBe(false) - }), {numRuns: 20}) - }) - - it('property: non-dry-run creates files with correct content', () => { - fc.assert(fc.property(safeSegment, fc.string({minLength: 1, maxLength: 100}), (name, content) => { - const fullPath = path.join(tmpDir, 'write', `${name}.txt`) - const rp = createRelativePath(`${name}.txt`, path.join(tmpDir, 'write'), () => 'write') - - const result = writeFileSafe({fullPath, content, type: 'test', relativePath: rp, dryRun: false, logger: noopLogger}) - expect(result.success).toBe(true) - expect(fs.readFileSync(fullPath, 'utf8')).toBe(content) - }), {numRuns: 20}) - }) -}) diff --git a/packages/desk-paths/src/index.ts b/packages/desk-paths/src/index.ts deleted file mode 100644 index 39ca1f08..00000000 --- a/packages/desk-paths/src/index.ts +++ /dev/null @@ -1,401 +0,0 @@ -import type {Buffer} from 'node:buffer' -import * as fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -/** - * Represents a fixed set of platform directory identifiers. - * - * `PlatformFixedDir` is a type that specifies platform-specific values. - * These values correspond to common operating system platforms and are - * used to identify directory structures or configurations unique to those systems. - * - * Valid values include: - * - 'win32': Represents the Windows operating system. - * - 'darwin': Represents the macOS operating system. - * - 'linux': Represents the Linux operating system. - * - * This type is typically used in contexts where platform-dependent logic - * or directory configurations are required. - */ -type PlatformFixedDir = 'win32' | 'darwin' | 'linux' - -/** - * Determines the Linux data directory based on the XDG_DATA_HOME environment - * variable or defaults to a directory under the user's home directory. - * - * @param {string} homeDir - The home directory path of the current user. - * @return {string} The resolved path to the Linux data directory. - */ -function getLinuxDataDir(homeDir: string): string { - const xdgDataHome = process.env['XDG_DATA_HOME'] - if (typeof xdgDataHome === 'string' && xdgDataHome.trim().length > 0) return xdgDataHome - return path.join(homeDir, '.local', 'share') -} - -/** - * Determines and returns the platform-specific directory for storing application data. - * The directory path is resolved based on the underlying operating system. - * - * @return {string} The resolved directory path specific to the current platform. - * @throws {Error} If the platform is unsupported. - */ -export function getPlatformFixedDir(): string { - const platform = process.platform as PlatformFixedDir - const homeDir = os.homedir() - - if (platform === 'win32') return process.env['LOCALAPPDATA'] ?? path.join(homeDir, 'AppData', 'Local') - if (platform === 'darwin') return path.join(homeDir, 'Library', 'Application Support') - if (platform === 'linux') return getLinuxDataDir(homeDir) - - throw new Error(`Unsupported platform: ${process.platform}`) -} - -/** - * Check if a path is a symbolic link (or junction on Windows). - * - * @param p - The path to check - * @returns true if the path is a symbolic link, false otherwise - */ -export function isSymlink(p: string): boolean { - try { - return fs.lstatSync(p).isSymbolicLink() - } - catch { - return false - } -} - -/** - * Get file stats without following symlinks. - * - * @param p - The path to get stats for - * @returns The fs.Stats object - */ -export function lstatSync(p: string): fs.Stats { - return fs.lstatSync(p) -} - -/** - * Ensure a directory exists, creating it recursively if needed. - * Idempotent: calling multiple times has the same effect as calling once. - * - * @param dir - The directory path to ensure exists - */ -export function ensureDir(dir: string): void { - fs.mkdirSync(dir, {recursive: true}) -} - -/** @internal */ -function ensureDirectory(dir: string): void { - ensureDir(dir) -} - -/** - * Create a symbolic link with cross-platform support. - * - * On Windows: - * - Uses 'junction' for directories (no admin privileges required) - * - Uses 'file' symlink for files (may require admin or developer mode) - * - * On Unix/macOS: - * - Uses standard symbolic links for both files and directories - * - * @param targetPath - The path the symlink should point to (must be absolute on Windows for junction) - * @param symlinkPath - The path where the symlink will be created - * @param type - Type of symlink: 'file' or 'dir' (default: 'dir') - */ -export function createSymlink(targetPath: string, symlinkPath: string, type: 'file' | 'dir' = 'dir'): void { - const parentDir = path.dirname(symlinkPath) - ensureDirectory(parentDir) - - if (fs.existsSync(symlinkPath)) { // Remove existing symlink or directory - const stat = fs.lstatSync(symlinkPath) - if (stat.isSymbolicLink()) { - if (process.platform === 'win32') fs.rmSync(symlinkPath, {recursive: true, force: true}) // Windows junction needs rmSync - else fs.unlinkSync(symlinkPath) - } else if (stat.isDirectory()) fs.rmSync(symlinkPath, {recursive: true}) - else fs.unlinkSync(symlinkPath) - } - - if (process.platform === 'win32' && type === 'dir') fs.symlinkSync(targetPath, symlinkPath, 'junction') // On Windows, use junction for directories (no admin needed) - else fs.symlinkSync(targetPath, symlinkPath, type) -} - -/** - * Remove a symbolic link (or junction on Windows) if it exists. - * - * @param symlinkPath - The path of the symlink to remove - */ -export function removeSymlink(symlinkPath: string): void { - if (!fs.existsSync(symlinkPath)) return - - const stat = fs.lstatSync(symlinkPath) - if (stat.isSymbolicLink()) { - if (process.platform === 'win32') fs.rmSync(symlinkPath, {recursive: true, force: true}) // Windows junction needs rmSync - else fs.unlinkSync(symlinkPath) - } -} - -/** - * Read the target of a symbolic link. - * - * @param symlinkPath - The path of the symlink - * @returns The target path, or null if not a symlink or an error occurred - */ -export function readSymlinkTarget(symlinkPath: string): string | null { - try { - if (!isSymlink(symlinkPath)) return null - return fs.readlinkSync(symlinkPath) - } - catch { - return null - } -} - -/** - * Check if a path exists (file, directory, or symlink). - * - * @param p - The path to check - * @returns true if the path exists - */ -export function existsSync(p: string): boolean { - return fs.existsSync(p) -} - -/** - * Delete a file, directory, or symlink/junction safely. - * Handles Windows junctions properly by using rmSync. - * - * @param p - The path to delete - */ -export function deletePathSync(p: string): void { - if (!fs.existsSync(p)) return - - const stat = fs.lstatSync(p) - if (stat.isSymbolicLink()) { - if (process.platform === 'win32') fs.rmSync(p, {recursive: true, force: true}) // Windows junction - else fs.unlinkSync(p) - } else if (stat.isDirectory()) fs.rmSync(p, {recursive: true, force: true}) - else fs.unlinkSync(p) -} // File Operations - Read, Write, Ensure - -/** - * Write a string or Buffer to a file, auto-creating parent directories. - * - * @param filePath - Absolute path to the file - * @param data - Content to write (string or Buffer) - * @param encoding - Encoding for string data (default: 'utf8') - */ -export function writeFileSync(filePath: string, data: string | Buffer, encoding: BufferEncoding = 'utf8'): void { - const parentDir = path.dirname(filePath) - ensureDir(parentDir) - if (typeof data === 'string') fs.writeFileSync(filePath, data, encoding) - else fs.writeFileSync(filePath, data) -} - -/** - * Read a file as a string. Throws with the path included in the error message on failure. - * - * @param filePath - Absolute path to the file - * @param encoding - Encoding (default: 'utf8') - * @returns The file content as a string - * @throws Error with path context if the file cannot be read - */ -export function readFileSync(filePath: string, encoding: BufferEncoding = 'utf8'): string { - try { - return fs.readFileSync(filePath, encoding) - } - catch (error) { - const msg = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to read file "${filePath}": ${msg}`) - } -} // Batch Deletion - Delete files and directories with error collection - -/** - * Error encountered during a batch deletion operation. - */ -export interface DeletionError { - readonly path: string - readonly error: unknown -} - -/** - * Result of a batch deletion operation. - */ -export interface DeletionResult { - readonly deleted: number - readonly errors: readonly DeletionError[] -} - -/** - * Delete multiple files. Skips non-existent files. Collects errors without throwing. - * - * @param files - Array of absolute file paths to delete - * @returns DeletionResult with count and errors - */ -export function deleteFiles(files: readonly string[]): DeletionResult { - let deleted = 0 - const errors: DeletionError[] = [] - - for (const file of files) { - try { - if (fs.existsSync(file)) { - deletePathSync(file) - deleted++ - } - } - catch (e) { - errors.push({path: file, error: e}) - } - } - - return {deleted, errors} -} - -/** - * Delete multiple directories. Sorts by depth descending so nested dirs are removed first. - * Skips non-existent directories. Collects errors without throwing. - * - * @param dirs - Array of absolute directory paths to delete - * @returns DeletionResult with count and errors - */ -export function deleteDirectories(dirs: readonly string[]): DeletionResult { - let deleted = 0 - const errors: DeletionError[] = [] - - const sorted = [...dirs].sort((a, b) => b.length - a.length) - - for (const dir of sorted) { - try { - if (fs.existsSync(dir)) { - fs.rmSync(dir, {recursive: true, force: true}) - deleted++ - } - } - catch (e) { - errors.push({path: dir, error: e}) - } - } - - return {deleted, errors} -} // RelativePath Factory - Construct RelativePath objects - -/** - * Directory path kind discriminator. - */ -export enum FilePathKind { - Relative = 'Relative', - Absolute = 'Absolute', - Root = 'Root' -} - -/** - * A path relative to a base directory. - */ -export interface RelativePath { - readonly pathKind: FilePathKind.Relative - readonly path: string - readonly basePath: string - readonly getDirectoryName: () => string - readonly getAbsolutePath: () => string -} - -/** - * Create a RelativePath from a path string, base path, and directory name function. - * - * @param pathStr - The relative path string - * @param basePath - The base directory for absolute path resolution - * @param dirNameFn - Function returning the directory name - * @returns A RelativePath object - */ -export function createRelativePath( - pathStr: string, - basePath: string, - dirNameFn: () => string -): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: dirNameFn, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -/** - * Create a RelativePath for a file within a parent directory. - * The getDirectoryName delegates to the parent directory's getDirectoryName. - * - * @param dir - Parent directory RelativePath - * @param fileName - Name of the file - * @returns A RelativePath pointing to the file - */ -export function createFileRelativePath(dir: RelativePath, fileName: string): RelativePath { - const filePath = path.join(dir.path, fileName) - return { - pathKind: FilePathKind.Relative, - path: filePath, - basePath: dir.basePath, - getDirectoryName: () => dir.getDirectoryName(), - getAbsolutePath: () => path.join(dir.basePath, filePath) - } -} // Safe Write - Dry-run aware file writing with error handling - -/** - * Logger interface for safe write operations. - */ -export interface WriteLogger { - readonly trace: (data: object) => void - readonly error: (data: object) => void -} - -/** - * Options for writeFileSafe. - */ -export interface SafeWriteOptions { - readonly fullPath: string - readonly content: string | Buffer - readonly type: string - readonly relativePath: RelativePath - readonly dryRun: boolean - readonly logger: WriteLogger -} - -/** - * Result of a safe write operation. - */ -export interface SafeWriteResult { - readonly path: RelativePath - readonly success: boolean - readonly skipped?: boolean - readonly error?: Error -} - -/** - * Write a file with dry-run support and error handling. - * Auto-creates parent directories. Returns a result object instead of throwing. - * - * @param options - Write options including path, content, dry-run flag, and logger - * @returns SafeWriteResult indicating success or failure - */ -export function writeFileSafe(options: SafeWriteOptions): SafeWriteResult { - const {fullPath, content, type, relativePath, dryRun, logger} = options - - if (dryRun) { - logger.trace({action: 'dryRun', type, path: fullPath}) - return {path: relativePath, success: true, skipped: false} - } - - try { - writeFileSync(fullPath, content) - logger.trace({action: 'write', type, path: fullPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - logger.error({action: 'write', type, path: fullPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } -} diff --git a/packages/desk-paths/tsconfig.eslint.json b/packages/desk-paths/tsconfig.eslint.json deleted file mode 100644 index 585b38ee..00000000 --- a/packages/desk-paths/tsconfig.eslint.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "skipLibCheck": true - }, - "include": [ - "src/**/*.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "env.d.ts", - "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" - ], - "exclude": [ - "../node_modules", - "dist", - "coverage" - ] -} diff --git a/packages/desk-paths/tsconfig.json b/packages/desk-paths/tsconfig.json deleted file mode 100644 index c31ab7f9..00000000 --- a/packages/desk-paths/tsconfig.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, // Projects - "target": "ESNext", // Language and Environment - "lib": [ - "ESNext" - ], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", // Path Mapping - "module": "ESNext", // Module Resolution - "moduleResolution": "Bundler", - "paths": { - "@/*": [ - "./src/*" - ] - }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, // Type Checking - Maximum Strictness - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, // Emit - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, // Interop Constraints - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true // Completeness - }, - "include": [ - "src/**/*", - "env.d.ts", - "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" - ], - "exclude": [ - "../node_modules", - "dist" - ] -} diff --git a/packages/desk-paths/tsconfig.lib.json b/packages/desk-paths/tsconfig.lib.json deleted file mode 100644 index b2449b37..00000000 --- a/packages/desk-paths/tsconfig.lib.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "noEmit": false, - "outDir": "../dist", - "skipLibCheck": true - }, - "include": [ - "src/**/*", - "env.d.ts" - ], - "exclude": [ - "../node_modules", - "dist", - "**/*.spec.ts", - "**/*.test.ts" - ] -} diff --git a/packages/desk-paths/tsconfig.test.json b/packages/desk-paths/tsconfig.test.json deleted file mode 100644 index 65c3c9ad..00000000 --- a/packages/desk-paths/tsconfig.test.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "lib": [ - "ESNext", - "DOM" - ], - "types": [ - "vitest/globals", - "node" - ] - }, - "include": [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "vitest.config.ts", - "vite.config.ts", - "env.d.ts" - ], - "exclude": [ - "../node_modules", - "dist" - ] -} diff --git a/packages/desk-paths/tsdown.config.ts b/packages/desk-paths/tsdown.config.ts deleted file mode 100644 index 5cfddf9a..00000000 --- a/packages/desk-paths/tsdown.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: { - '@': resolve('src') - }, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/desk-paths/vite.config.ts b/packages/desk-paths/vite.config.ts deleted file mode 100644 index 2dcc5646..00000000 --- a/packages/desk-paths/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - } - } -}) diff --git a/packages/desk-paths/vitest.config.ts b/packages/desk-paths/vitest.config.ts deleted file mode 100644 index bd344383..00000000 --- a/packages/desk-paths/vitest.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {fileURLToPath} from 'node:url' - -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' - -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: { - enabled: true, - tsconfig: './tsconfig.test.json' - }, - testTimeout: 30000, // Property-based tests run more iterations - onConsoleLog: () => false, // Minimal output: suppress console logs, show summary only - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/', - 'dist/', - '**/*.test.ts', - '**/*.property.test.ts' - ] - } - } - }) -) diff --git a/packages/plugin-agentskills-compact/eslint.config.ts b/packages/plugin-agentskills-compact/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-agentskills-compact/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-agentskills-compact/package.json b/packages/plugin-agentskills-compact/package.json deleted file mode 100644 index db8b9d5d..00000000 --- a/packages/plugin-agentskills-compact/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-agentskills-compact", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Generic Agent Skills (compact) output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-agentskills-compact/src/GenericSkillsOutputPlugin.test.ts b/packages/plugin-agentskills-compact/src/GenericSkillsOutputPlugin.test.ts deleted file mode 100644 index f09ed44c..00000000 --- a/packages/plugin-agentskills-compact/src/GenericSkillsOutputPlugin.test.ts +++ /dev/null @@ -1,447 +0,0 @@ -import type { - CollectedInputContext, - OutputPluginContext, - OutputWriteContext, - RelativePath, - SkillChildDoc, - SkillPrompt, - SkillResource, - SkillYAMLFrontMatter -} from '@truenine/plugin-shared' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {GenericSkillsOutputPlugin} from './GenericSkillsOutputPlugin' - -vi.mock('node:fs') -vi.mock('node:os') - -describe('genericSkillsOutputPlugin', () => { - const mockWorkspaceDir = '/workspace/test' - const mockHomeDir = '/home/user' - let plugin: GenericSkillsOutputPlugin - - beforeEach(() => { - plugin = new GenericSkillsOutputPlugin() - vi.mocked(os.homedir).mockReturnValue(mockHomeDir) - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.mkdirSync).mockReturnValue(void 0) - vi.mocked(fs.writeFileSync).mockReturnValue(void 0) - vi.mocked(fs.symlinkSync).mockReturnValue(void 0) - vi.mocked(fs.lstatSync).mockReturnValue({isSymbolicLink: () => false, isDirectory: () => false} as fs.Stats) - }) - - 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 createMockSkillPrompt( - name: string, - content: string, - options?: { - description?: string - keywords?: readonly string[] - displayName?: string - author?: string - version?: string - mcpConfig?: {rawContent: string} - childDocs?: {relativePath: string, content: string}[] - resources?: {relativePath: string, content: string, encoding: 'text' | 'base64'}[] - } - ): SkillPrompt { - const yamlFrontMatter: SkillYAMLFrontMatter = { - name, - description: options?.description ?? `Description for ${name}`, - namingCase: 0 as any, - keywords: options?.keywords ?? [], - displayName: options?.displayName ?? name, - author: options?.author ?? '', - version: options?.version ?? '' - } - - const childDocs: SkillChildDoc[] | undefined = options?.childDocs?.map(d => ({ - type: PromptKind.SkillChildDoc, - relativePath: d.relativePath, - content: d.content, - markdownContents: [], - dir: createMockRelativePath(d.relativePath, '/shadow/.skills'), - length: d.content.length, - filePathKind: FilePathKind.Relative - })) - - const resources: SkillResource[] | undefined = options?.resources?.map(r => ({ - type: PromptKind.SkillResource, - relativePath: r.relativePath, - content: r.content, - encoding: r.encoding, - extension: path.extname(r.relativePath), - fileName: path.basename(r.relativePath), - category: 'other' as const, - length: r.content.length - })) - - return { - type: PromptKind.Skill, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - markdownContents: [], - yamlFrontMatter, - dir: createMockRelativePath(name, '/shadow/.skills'), - mcpConfig: options?.mcpConfig != null - ? { - type: PromptKind.SkillMcpConfig, - mcpServers: {}, - rawContent: options.mcpConfig.rawContent - } - : void 0, - childDocs, - resources - } as unknown as SkillPrompt - } - - function createMockOutputWriteContext( - collectedInputContext: Partial, - dryRun = false - ): OutputWriteContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [] - }, - ideConfigFiles: [], - ...collectedInputContext - } as CollectedInputContext, - dryRun, - registeredPluginNames: [], - logger: {trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: vi.fn() as any - } - } - - function createMockOutputPluginContext( - collectedInputContext: Partial - ): OutputPluginContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [] - }, - ideConfigFiles: [], - ...collectedInputContext - } as CollectedInputContext, - logger: {trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: vi.fn() as any - } - } - - describe('canWrite', () => { - it('should return false when no skills exist', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'test-project', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - } - }) - - const result = await plugin.canWrite(ctx) - expect(result).toBe(false) - }) - - it('should return false when no projects exist', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [] - }, - skills: [createMockSkillPrompt('test-skill', 'content')] - }) - - const result = await plugin.canWrite(ctx) - expect(result).toBe(false) - }) - - it('should return true when skills and projects exist', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'test-project', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [createMockSkillPrompt('test-skill', 'content')] - }) - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should register .skills directory for each project', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - {name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}, - {name: 'project2', dirFromWorkspacePath: createMockRelativePath('project2', mockWorkspaceDir)} - ] - }, - skills: [createMockSkillPrompt('test-skill', 'content')] - }) - - const results = await plugin.registerProjectOutputDirs(ctx) - - expect(results).toHaveLength(2) - expect(results[0]?.path).toBe(path.join('project1', '.skills')) - expect(results[1]?.path).toBe(path.join('project2', '.skills')) - }) - - it('should return empty array when no skills exist', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - } - }) - - const results = await plugin.registerProjectOutputDirs(ctx) - expect(results).toHaveLength(0) - }) - }) - - describe('registerGlobalOutputDirs', () => { - it('should register ~/.skills/ directory when skills exist', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [createMockSkillPrompt('test-skill', 'content')] - }) - - const results = await plugin.registerGlobalOutputDirs(ctx) - - expect(results).toHaveLength(1) - const pathValue = results[0]?.path.replaceAll('\\', '/') - const expected = path.join('.aindex', '.skills').replaceAll('\\', '/') - expect(pathValue).toBe(expected) - expect(results[0]?.basePath).toBe(mockHomeDir) - }) - - it('should return empty array when no skills exist', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - } - }) - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(0) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register SKILL.md in ~/.skills/ for each skill', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [ - createMockSkillPrompt('skill-a', 'content a'), - createMockSkillPrompt('skill-b', 'content b') - ] - }) - - const results = await plugin.registerGlobalOutputFiles(ctx) - - expect(results).toHaveLength(2) - expect(results[0]?.path).toBe(path.join('.aindex', '.skills', 'skill-a', 'SKILL.md')) - expect(results[1]?.path).toBe(path.join('.aindex', '.skills', 'skill-b', 'SKILL.md')) - expect(results[0]?.basePath).toBe(mockHomeDir) - }) - - it('should register mcp.json when skill has MCP config', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [createMockSkillPrompt('test-skill', 'content', {mcpConfig: {rawContent: '{}'}})] - }) - - const results = await plugin.registerGlobalOutputFiles(ctx) - - expect(results).toHaveLength(2) - expect(results[0]?.path).toBe(path.join('.aindex', '.skills', 'test-skill', 'SKILL.md')) - expect(results[1]?.path).toBe(path.join('.aindex', '.skills', 'test-skill', 'mcp.json')) - }) - }) - - describe('writeGlobalOutputs', () => { - it('should write SKILL.md to ~/.skills/ with front matter', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [createMockSkillPrompt('test-skill', '# Skill Content', { - description: 'A test skill', - keywords: ['test', 'demo'] - })] - }) - - const results = await plugin.writeGlobalOutputs(ctx) - - expect(results.files).toHaveLength(1) - expect(results.files[0]?.success).toBe(true) - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] - expect(writeCall).toBeDefined() - expect(writeCall?.[0]).toContain(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill', 'SKILL.md')) - - const writtenContent = writeCall?.[1] as string - expect(writtenContent).toContain('name: test-skill') - expect(writtenContent).toContain('description: A test skill') - expect(writtenContent).toContain('# Skill Content') - }) - - it('should support dry-run mode', async () => { - const ctx = createMockOutputWriteContext( - { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [createMockSkillPrompt('test-skill', 'content')] - }, - true - ) - - const results = await plugin.writeGlobalOutputs(ctx) - - expect(results.files).toHaveLength(1) - expect(results.files[0]?.success).toBe(true) - expect(results.files[0]?.skipped).toBe(false) - expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() - }) - }) - - describe('writeProjectOutputs', () => { - it('should create symlinks for each skill in each project', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false) // Symlink doesn't exist yet - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - {name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}, - {name: 'project2', dirFromWorkspacePath: createMockRelativePath('project2', mockWorkspaceDir)} - ] - }, - skills: [createMockSkillPrompt('test-skill', 'content')] - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(2) - expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledTimes(2) - - expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledWith( // Verify symlinks point from project to global - expect.stringContaining(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill')), - expect.stringContaining(path.join('project1', '.skills', 'test-skill')), - expect.anything() - ) - expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledWith( - expect.stringContaining(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill')), - expect.stringContaining(path.join('project2', '.skills', 'test-skill')), - expect.anything() - ) - }) - - it('should support dry-run mode for symlinks', async () => { - const ctx = createMockOutputWriteContext( - { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [createMockSkillPrompt('test-skill', 'content')] - }, - true - ) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(1) - expect(results.files[0]?.success).toBe(true) - expect(results.files[0]?.skipped).toBe(false) - expect(vi.mocked(fs.symlinkSync)).not.toHaveBeenCalled() - }) - - it('should skip project without dirFromWorkspacePath', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1'}] - }, - skills: [createMockSkillPrompt('test-skill', 'content')] - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(0) - expect(vi.mocked(fs.symlinkSync)).not.toHaveBeenCalled() - }) - - it('should return empty results when no skills exist', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - } - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(0) - expect(results.dirs).toHaveLength(0) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should register symlink paths for each skill in each project', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [ - createMockSkillPrompt('skill-a', 'content a'), - createMockSkillPrompt('skill-b', 'content b') - ] - }) - - const results = await plugin.registerProjectOutputFiles(ctx) - - expect(results).toHaveLength(2) - expect(results[0]?.path).toBe(path.join('.skills', 'skill-a')) - expect(results[1]?.path).toBe(path.join('.skills', 'skill-b')) - }) - }) -}) diff --git a/packages/plugin-agentskills-compact/src/GenericSkillsOutputPlugin.ts b/packages/plugin-agentskills-compact/src/GenericSkillsOutputPlugin.ts deleted file mode 100644 index 8a53d64e..00000000 --- a/packages/plugin-agentskills-compact/src/GenericSkillsOutputPlugin.ts +++ /dev/null @@ -1,468 +0,0 @@ -import type { - OutputPluginContext, - OutputWriteContext, - SkillPrompt, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' - -import {Buffer} from 'node:buffer' -import * as fs from 'node:fs' -import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {FilePathKind} from '@truenine/plugin-shared' - -const PROJECT_SKILLS_DIR = '.skills' -const GLOBAL_SKILLS_DIR = '.aindex/.skills' -const OLD_GLOBAL_SKILLS_DIR = '.skills' // 向后兼容:旧的全局 skills 目录 -const SKILL_FILE_NAME = 'SKILL.md' -const MCP_CONFIG_FILE = 'mcp.json' - -/** - * Output plugin that writes skills to a global location (~/.skills/) and - * creates symlinks in each project pointing to the global skill directories. - * - * This approach reduces disk space usage when multiple projects use the same skills. - * - * Structure: - * - Global: ~/.skills//SKILL.md, mcp.json, child docs, resources - * - Project: /.skills/ → ~/.skills/ (symlink) - */ -export class GenericSkillsOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('GenericSkillsOutputPlugin', {globalConfigDir: GLOBAL_SKILLS_DIR, outputFileName: SKILL_FILE_NAME}) - - this.registerCleanEffect('legacy-global-skills-cleanup', async ctx => { // 向后兼容:clean 时清理旧的 ~/.skills 目录 - const oldGlobalSkillsDir = this.joinPath(this.getHomeDir(), OLD_GLOBAL_SKILLS_DIR) - if (!this.existsSync(oldGlobalSkillsDir)) return {success: true, description: 'Legacy global skills dir does not exist, nothing to clean'} - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'legacyCleanup', path: oldGlobalSkillsDir}) - return {success: true, description: `Would clean legacy global skills dir: ${oldGlobalSkillsDir}`} - } - try { - const entries = this.readdirSync(oldGlobalSkillsDir, {withFileTypes: true}) // 只删除 skill 子目录(避免误删用户其他文件) - let cleanedCount = 0 - for (const entry of entries) { - if (entry.isDirectory()) { - const skillDir = this.joinPath(oldGlobalSkillsDir, entry.name) - const skillFile = this.joinPath(skillDir, SKILL_FILE_NAME) - if (this.existsSync(skillFile)) { // 确认是 skill 目录(包含 SKILL.md)才删除 - fs.rmSync(skillDir, {recursive: true}) - cleanedCount++ - } - } - } - const remainingEntries = this.readdirSync(oldGlobalSkillsDir) // 如果目录为空则删除目录本身 - if (remainingEntries.length === 0) fs.rmdirSync(oldGlobalSkillsDir) - this.log.trace({action: 'clean', type: 'legacySkills', dir: oldGlobalSkillsDir, cleanedCount}) - return {success: true, description: `Cleaned ${cleanedCount} legacy skills from ${oldGlobalSkillsDir}`} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'clean', type: 'legacySkills', dir: oldGlobalSkillsDir, error: errMsg}) - return {success: false, description: `Failed to clean legacy skills dir`, error: error as Error} - } - }) - } - - private getGlobalSkillsDir(): string { - return this.joinPath(this.getHomeDir(), GLOBAL_SKILLS_DIR) - } - - async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - const {skills} = ctx.collectedInputContext - - if (skills == null || skills.length === 0) return results - - for (const project of projects) { // Register /.skills/ for cleanup (symlink directory) - if (project.dirFromWorkspacePath == null) continue - - const skillsDir = this.joinPath(project.dirFromWorkspacePath.path, PROJECT_SKILLS_DIR) - results.push({ - pathKind: FilePathKind.Relative, - path: skillsDir, - basePath: project.dirFromWorkspacePath.basePath, - getDirectoryName: () => PROJECT_SKILLS_DIR, - getAbsolutePath: () => this.joinPath(project.dirFromWorkspacePath!.basePath, skillsDir) - }) - } - - return results - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - const {skills} = ctx.collectedInputContext - - if (skills == null || skills.length === 0) return results - - for (const project of projects) { // Register symlink paths (skills in project are now symlinks) - if (project.dirFromWorkspacePath == null) continue - - const projectSkillsDir = this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path, PROJECT_SKILLS_DIR) - - for (const skill of skills) { - const skillName = skill.yamlFrontMatter.name - const skillDir = this.joinPath(projectSkillsDir, skillName) - - results.push({ // Register skill directory symlink - pathKind: FilePathKind.Relative, - path: this.joinPath(PROJECT_SKILLS_DIR, skillName), - basePath: this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path), - getDirectoryName: () => skillName, - getAbsolutePath: () => skillDir - }) - } - } - - return results - } - - async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { - const {skills} = ctx.collectedInputContext - - if (skills == null || skills.length === 0) return [] - - const globalSkillsDir = this.getGlobalSkillsDir() - return [{ - pathKind: FilePathKind.Relative, - path: GLOBAL_SKILLS_DIR, - basePath: this.getHomeDir(), - getDirectoryName: () => GLOBAL_SKILLS_DIR, - getAbsolutePath: () => globalSkillsDir - }] - } - - async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {skills} = ctx.collectedInputContext - - if (skills == null || skills.length === 0) return results - - const globalSkillsDir = this.getGlobalSkillsDir() - - for (const skill of skills) { - const skillName = skill.yamlFrontMatter.name - const skillDir = this.joinPath(globalSkillsDir, skillName) - - results.push({ // Register SKILL.md - pathKind: FilePathKind.Relative, - path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, SKILL_FILE_NAME), - basePath: this.getHomeDir(), - getDirectoryName: () => skillName, - getAbsolutePath: () => this.joinPath(skillDir, SKILL_FILE_NAME) - }) - - if (skill.mcpConfig != null) { // Register mcp.json if skill has MCP configuration - results.push({ - pathKind: FilePathKind.Relative, - path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, MCP_CONFIG_FILE), - basePath: this.getHomeDir(), - getDirectoryName: () => skillName, - getAbsolutePath: () => this.joinPath(skillDir, MCP_CONFIG_FILE) - }) - } - - if (skill.childDocs != null) { // Register child docs (convert .mdx to .md) - for (const childDoc of skill.childDocs) { - const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') - results.push({ - pathKind: FilePathKind.Relative, - path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, outputRelativePath), - basePath: this.getHomeDir(), - getDirectoryName: () => skillName, - getAbsolutePath: () => this.joinPath(skillDir, outputRelativePath) - }) - } - } - - if (skill.resources != null) { // Register resources - for (const resource of skill.resources) { - results.push({ - pathKind: FilePathKind.Relative, - path: this.joinPath(GLOBAL_SKILLS_DIR, skillName, resource.relativePath), - basePath: this.getHomeDir(), - getDirectoryName: () => skillName, - getAbsolutePath: () => this.joinPath(skillDir, resource.relativePath) - }) - } - } - } - - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {skills} = ctx.collectedInputContext - const {projects} = ctx.collectedInputContext.workspace - - if (skills == null || skills.length === 0) { - this.log.trace({action: 'skip', reason: 'noSkills'}) - return false - } - - if (projects.length !== 0) return true - - this.log.trace({action: 'skip', reason: 'noProjects'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const {projects} = ctx.collectedInputContext.workspace - const {skills} = ctx.collectedInputContext - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - if (skills == null || skills.length === 0) return {files: fileResults, dirs: dirResults} - - const globalSkillsDir = this.getGlobalSkillsDir() - - for (const project of projects) { // Create symlinks for each project - if (project.dirFromWorkspacePath == null) continue - - const projectSkillsDir = this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path, PROJECT_SKILLS_DIR) - - for (const skill of skills) { - const skillName = skill.yamlFrontMatter.name - const globalSkillDir = this.joinPath(globalSkillsDir, skillName) - const projectSkillDir = this.joinPath(projectSkillsDir, skillName) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: this.joinPath(PROJECT_SKILLS_DIR, skillName), - basePath: this.joinPath(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path), - getDirectoryName: () => skillName, - getAbsolutePath: () => projectSkillDir - } - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'symlink', target: globalSkillDir, link: projectSkillDir}) - fileResults.push({path: relativePath, success: true, skipped: false}) - continue - } - - try { - this.createSymlink(globalSkillDir, projectSkillDir, 'dir') - this.log.trace({action: 'symlink', type: 'skill', target: globalSkillDir, link: projectSkillDir}) - fileResults.push({path: relativePath, success: true}) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'symlink', type: 'skill', target: globalSkillDir, link: projectSkillDir, error: errMsg}) - fileResults.push({path: relativePath, success: false, error: error as Error}) - } - } - } - - return {files: fileResults, dirs: dirResults} - } - - async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {skills} = ctx.collectedInputContext - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - if (skills == null || skills.length === 0) return {files: fileResults, dirs: dirResults} - - const globalSkillsDir = this.getGlobalSkillsDir() - - for (const skill of skills) { // Write all skills to global ~/.skills/ directory - const skillResults = await this.writeSkill(ctx, skill, globalSkillsDir) - fileResults.push(...skillResults) - } - - return {files: fileResults, dirs: dirResults} - } - - private async writeSkill( - ctx: OutputWriteContext, - skill: SkillPrompt, - skillsDir: string - ): Promise { - const results: WriteResult[] = [] - const skillName = skill.yamlFrontMatter.name - const skillDir = this.joinPath(skillsDir, skillName) - const skillFilePath = this.joinPath(skillDir, SKILL_FILE_NAME) - - const skillRelativePath: RelativePath = { // Create RelativePath for SKILL.md - pathKind: FilePathKind.Relative, - path: SKILL_FILE_NAME, - basePath: skillDir, - getDirectoryName: () => skillName, - getAbsolutePath: () => skillFilePath - } - - const frontMatterData = this.buildSkillFrontMatter(skill) // Build SKILL.md content with front matter - const bodyContent = skill.content as string - const skillContent = buildMarkdownWithFrontMatter(frontMatterData, bodyContent) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'skill', path: skillFilePath}) - results.push({path: skillRelativePath, success: true, skipped: false}) - } else { - try { - this.ensureDirectory(skillDir) - this.writeFileSync(skillFilePath, skillContent) - this.log.trace({action: 'write', type: 'skill', path: skillFilePath}) - results.push({path: skillRelativePath, success: true}) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'skill', path: skillFilePath, error: errMsg}) - results.push({path: skillRelativePath, success: false, error: error as Error}) - } - } - - if (skill.mcpConfig != null) { // Write mcp.json if skill has MCP configuration - const mcpResult = await this.writeMcpConfig(ctx, skill, skillDir) - results.push(mcpResult) - } - - if (skill.childDocs != null) { // Write child docs - for (const childDoc of skill.childDocs) { - const childDocResult = await this.writeChildDoc(ctx, childDoc, skillDir, skillName) - results.push(childDocResult) - } - } - - if (skill.resources != null) { // Write resources - for (const resource of skill.resources) { - const resourceResult = await this.writeResource(ctx, resource, skillDir, skillName) - results.push(resourceResult) - } - } - - return results - } - - private buildSkillFrontMatter(skill: SkillPrompt): Record { - const fm = skill.yamlFrontMatter - return { - name: fm.name, - description: fm.description, - ...fm.displayName != null && {displayName: fm.displayName}, - ...fm.keywords != null && fm.keywords.length > 0 && {keywords: fm.keywords}, - ...fm.author != null && {author: fm.author}, - ...fm.version != null && {version: fm.version}, - ...fm.allowTools != null && fm.allowTools.length > 0 && {allowTools: fm.allowTools} - } - } - - private async writeMcpConfig( - ctx: OutputWriteContext, - skill: SkillPrompt, - skillDir: string - ): Promise { - const skillName = skill.yamlFrontMatter.name - const mcpConfigPath = this.joinPath(skillDir, MCP_CONFIG_FILE) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: MCP_CONFIG_FILE, - basePath: skillDir, - getDirectoryName: () => skillName, - getAbsolutePath: () => mcpConfigPath - } - - const mcpConfigContent = skill.mcpConfig!.rawContent - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'mcpConfig', path: mcpConfigPath}) - return {path: relativePath, success: true, skipped: false} - } - - try { - this.ensureDirectory(skillDir) - this.writeFileSync(mcpConfigPath, mcpConfigContent) - this.log.trace({action: 'write', type: 'mcpConfig', path: mcpConfigPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'mcpConfig', path: mcpConfigPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private async writeChildDoc( - ctx: OutputWriteContext, - childDoc: {relativePath: string, content: unknown}, - skillDir: string, - skillName: string - ): Promise { - const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') // Convert .mdx to .md for output - const childDocPath = this.joinPath(skillDir, outputRelativePath) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: outputRelativePath, - basePath: skillDir, - getDirectoryName: () => skillName, - getAbsolutePath: () => childDocPath - } - - const content = childDoc.content as string - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'childDoc', path: childDocPath}) - return {path: relativePath, success: true, skipped: false} - } - - try { - const parentDir = this.dirname(childDocPath) - this.ensureDirectory(parentDir) - this.writeFileSync(childDocPath, content) - this.log.trace({action: 'write', type: 'childDoc', path: childDocPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'childDoc', path: childDocPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private async writeResource( - ctx: OutputWriteContext, - resource: {relativePath: string, content: string, encoding: 'text' | 'base64'}, - skillDir: string, - skillName: string - ): Promise { - const resourcePath = this.joinPath(skillDir, resource.relativePath) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: resource.relativePath, - basePath: skillDir, - getDirectoryName: () => skillName, - getAbsolutePath: () => resourcePath - } - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'resource', path: resourcePath}) - return {path: relativePath, success: true, skipped: false} - } - - try { - const parentDir = this.dirname(resourcePath) - this.ensureDirectory(parentDir) - - if (resource.encoding === 'base64') { // Handle binary vs text encoding - const buffer = Buffer.from(resource.content, 'base64') - this.writeFileSyncBuffer(resourcePath, buffer) - } else this.writeFileSync(resourcePath, resource.content) - - this.log.trace({action: 'write', type: 'resource', path: resourcePath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'resource', path: resourcePath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } -} diff --git a/packages/plugin-agentskills-compact/src/index.ts b/packages/plugin-agentskills-compact/src/index.ts deleted file mode 100644 index abe6e9b6..00000000 --- a/packages/plugin-agentskills-compact/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - GenericSkillsOutputPlugin -} from './GenericSkillsOutputPlugin' diff --git a/packages/plugin-agentskills-compact/tsconfig.eslint.json b/packages/plugin-agentskills-compact/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-agentskills-compact/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-agentskills-compact/tsconfig.json b/packages/plugin-agentskills-compact/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-agentskills-compact/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-agentskills-compact/tsconfig.lib.json b/packages/plugin-agentskills-compact/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-agentskills-compact/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-agentskills-compact/tsconfig.test.json b/packages/plugin-agentskills-compact/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-agentskills-compact/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-agentskills-compact/tsdown.config.ts b/packages/plugin-agentskills-compact/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-agentskills-compact/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-agentskills-compact/vite.config.ts b/packages/plugin-agentskills-compact/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-agentskills-compact/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-agentskills-compact/vitest.config.ts b/packages/plugin-agentskills-compact/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-agentskills-compact/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-agentsmd/eslint.config.ts b/packages/plugin-agentsmd/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-agentsmd/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-agentsmd/package.json b/packages/plugin-agentsmd/package.json deleted file mode 100644 index 1971b60f..00000000 --- a/packages/plugin-agentsmd/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-agentsmd", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "AGENTS.md output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-agentsmd/src/AgentsOutputPlugin.ts b/packages/plugin-agentsmd/src/AgentsOutputPlugin.ts deleted file mode 100644 index c0c9a835..00000000 --- a/packages/plugin-agentsmd/src/AgentsOutputPlugin.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { - OutputPluginContext, - OutputWriteContext, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' - -const PROJECT_MEMORY_FILE = 'AGENTS.md' - -export class AgentsOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('AgentsOutputPlugin', {outputFileName: PROJECT_MEMORY_FILE}) - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - - for (const project of projects) { - if (project.rootMemoryPrompt != null && project.dirFromWorkspacePath != null) { // Root memory prompt uses project.dirFromWorkspacePath - results.push(this.createFileRelativePath(project.dirFromWorkspacePath, PROJECT_MEMORY_FILE)) - } - - if (project.childMemoryPrompts != null) { - for (const child of project.childMemoryPrompts) { - if (child.dir != null && this.isRelativePath(child.dir)) results.push(this.createFileRelativePath(child.dir, PROJECT_MEMORY_FILE)) - } - } - } - - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {workspace} = ctx.collectedInputContext - const hasProjectOutputs = workspace.projects.some( - p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 - ) - - if (hasProjectOutputs) return true - - this.log.trace({action: 'skip', reason: 'noOutputs'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const {projects} = ctx.collectedInputContext.workspace - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - for (const project of projects) { - const projectName = project.name ?? 'unknown' - const projectDir = project.dirFromWorkspacePath - - if (projectDir == null) continue - - if (project.rootMemoryPrompt != null) { // Write root memory prompt (only if exists) - const result = await this.writePromptFile(ctx, projectDir, project.rootMemoryPrompt.content as string, `project:${projectName}/root`) - fileResults.push(result) - } - - if (project.childMemoryPrompts != null) { // Write children memory prompts - for (const child of project.childMemoryPrompts) { - const childResult = await this.writePromptFile(ctx, child.dir, child.content as string, `project:${projectName}/child:${child.workingChildDirectoryPath?.path ?? 'unknown'}`) - fileResults.push(childResult) - } - } - } - - return {files: fileResults, dirs: dirResults} - } -} diff --git a/packages/plugin-agentsmd/src/index.ts b/packages/plugin-agentsmd/src/index.ts deleted file mode 100644 index 2a8505e4..00000000 --- a/packages/plugin-agentsmd/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - AgentsOutputPlugin -} from './AgentsOutputPlugin' diff --git a/packages/plugin-agentsmd/tsconfig.eslint.json b/packages/plugin-agentsmd/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-agentsmd/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-agentsmd/tsconfig.json b/packages/plugin-agentsmd/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-agentsmd/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-agentsmd/tsconfig.lib.json b/packages/plugin-agentsmd/tsconfig.lib.json deleted file mode 100644 index 73898d16..00000000 --- a/packages/plugin-agentsmd/tsconfig.lib.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "noEmit": false, - "outDir": "../dist", - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-agentsmd/tsconfig.test.json b/packages/plugin-agentsmd/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-agentsmd/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-agentsmd/tsdown.config.ts b/packages/plugin-agentsmd/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-agentsmd/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-agentsmd/vite.config.ts b/packages/plugin-agentsmd/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-agentsmd/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-agentsmd/vitest.config.ts b/packages/plugin-agentsmd/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-agentsmd/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-antigravity/eslint.config.ts b/packages/plugin-antigravity/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-antigravity/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-antigravity/package.json b/packages/plugin-antigravity/package.json deleted file mode 100644 index 8bc2d0ae..00000000 --- a/packages/plugin-antigravity/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-antigravity", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Antigravity output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-antigravity/src/AntigravityOutputPlugin.test.ts b/packages/plugin-antigravity/src/AntigravityOutputPlugin.test.ts deleted file mode 100644 index 6884c8c9..00000000 --- a/packages/plugin-antigravity/src/AntigravityOutputPlugin.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import type {RelativePath} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import {FilePathKind} from '@truenine/plugin-shared' -import {describe, expect, it, vi} from 'vitest' -import {AntigravityOutputPlugin} from './AntigravityOutputPlugin' - -vi.mock('node:fs') -vi.mock('node:os') - -describe('antigravityOutputPlugin', () => { - const plugin = new AntigravityOutputPlugin() - const projectBasePath = '/user/project' - const projectPath = 'my-project' - const homeDir = '/home/user' - - vi.mocked(os.homedir).mockReturnValue(homeDir) - - const projectDir: RelativePath = { - pathKind: FilePathKind.Relative, - path: projectPath, - basePath: projectBasePath, - getDirectoryName: () => 'my-project', - getAbsolutePath: () => `${projectBasePath}/${projectPath}` - } - - const mockSkills: any[] = [ - { - dir: { - pathKind: FilePathKind.Relative, - path: 'my-skill', - basePath: projectBasePath, - getDirectoryName: () => 'my-skill', - getAbsolutePath: () => `${projectBasePath}/my-skill` - }, - content: '# My Skill', - yamlFrontMatter: {name: 'custom-skill'}, - resources: [ - {relativePath: 'res.txt', content: 'resource content'} - ], - childDocs: [ - { - dir: { - pathKind: FilePathKind.Relative, - path: 'doc.mdx', - basePath: projectBasePath, - getDirectoryName: () => 'doc', - getAbsolutePath: () => `${projectBasePath}/doc.mdx` - }, - content: 'doc content' - } - ] - } - ] - - const mockFastCommands: any[] = [ - { - commandName: 'cmd1', - series: 'custom', - dir: { - pathKind: FilePathKind.Relative, - path: 'cmd1.md', - basePath: projectBasePath, - getDirectoryName: () => 'cmd1', - getAbsolutePath: () => `${projectBasePath}/cmd1.md` - }, - content: '# Command 1', - yamlFrontMatter: {description: 'A description', other: 'ignore'} - }, - { - commandName: 'cmd2', - series: 'custom', - dir: { - pathKind: FilePathKind.Relative, - path: 'cmd2.md', - basePath: projectBasePath, - getDirectoryName: () => 'cmd2', - getAbsolutePath: () => `${projectBasePath}/cmd2.md` - }, - content: '# Command 2', - rawMdxContent: '---\ntitle: original\n---\n# Command 2 Raw', - yamlFrontMatter: {description: 'Desc 2'} - } - ] - - const mockInputContext: any = { - globalMemory: null, - workspace: { - projects: [ - { - name: 'p1', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: null - } - ] - }, - skills: mockSkills, - fastCommands: mockFastCommands - } - - const mockContext: any = { - collectedInputContext: mockInputContext, - tools: { - readProjectFile: vi.fn() - }, - config: { - plugins: [] - }, - dryRun: false - } - - it('should register output directories for clean (project local)', async () => { - const ctx = { - collectedInputContext: { - workspace: { - projects: [ - { - dirFromWorkspacePath: projectDir - } - ] - } - } - } as any - - const results = await plugin.registerProjectOutputDirs(ctx) - expect(results).toHaveLength(2) // Should still register local project directories for cleanup - const paths = results.map(r => r.path.replaceAll('\\', '/')) - expect(paths.some(p => p.includes('.agent/skills'))).toBe(true) - expect(paths.some(p => p.includes('.agent/workflows'))).toBe(true) - }) - - it('should register output files for skills (global)', async () => { - const ctx = { - collectedInputContext: { - workspace: { - projects: [] // even with no projects, global files should be registered if skills exist - }, - skills: mockSkills - } - } as any - - const results = await plugin.registerProjectOutputFiles(ctx) - expect(results).toHaveLength(3) - const paths = new Set(results.map(r => r.path.replaceAll('\\', '/'))) - expect(paths.has('SKILL.md')).toBe(true) // r.path is now the relative filename - expect(paths.has('doc.md')).toBe(true) - expect(paths.has('res.txt')).toBe(true) - - const globalPathPart = '.gemini/antigravity/skills' // Check if base paths are global - const basePaths = results.map(r => r.basePath.replaceAll('\\', '/')) - expect(basePaths.every(p => p.includes(globalPathPart))).toBe(true) - }) - - it('should write skills correctly to global dir', async () => { - await plugin.writeProjectOutputs(mockContext) - - const expectedSkillPath = '.gemini/antigravity/skills/custom-skill/SKILL.md' // Global path: /home/user/.gemini/antigravity/skills/custom-skill/SKILL.md // Check for global path write - - const skillCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => - String(call[0]).replaceAll('\\', '/').includes(expectedSkillPath)) - - expect(skillCall).toBeDefined() - expect(skillCall![1]).toContain('# My Skill') - - const resCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => - String(call[0]).replaceAll('\\', '/').includes('.gemini/antigravity/skills/custom-skill/res.txt')) - expect(resCall).toBeDefined() - expect(resCall![1]).toBe('resource content') - - const docCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => - String(call[0]).replaceAll('\\', '/').includes('.gemini/antigravity/skills/custom-skill/doc.md')) - expect(docCall).toBeDefined() - expect(docCall![1]).toBe('doc content') - }) - - it('should write workflows (fast commands) correctly to global dir', async () => { - await plugin.writeProjectOutputs(mockContext) - - const expectedWorkflowPath = '.gemini/antigravity/workflows' // Expected: /home/user/.gemini/antigravity/workflows/custom-cmd1.md - - const cmd1Call = vi.mocked(fs.writeFileSync).mock.calls.find(call => { - const normalizedPath = String(call[0]).replaceAll('\\', '/') - return normalizedPath.includes(expectedWorkflowPath) && normalizedPath.includes('custom-cmd1.md') - }) - expect(cmd1Call).toBeDefined() - const cmd1Content = cmd1Call![1] as string - expect(cmd1Content).toContain('description: A description') - - const cmd2Call = vi.mocked(fs.writeFileSync).mock.calls.find(call => { - const normalizedPath = String(call[0]).replaceAll('\\', '/') - return normalizedPath.includes(expectedWorkflowPath) && normalizedPath.includes('custom-cmd2.md') - }) - expect(cmd2Call).toBeDefined() - const cmd2Content = cmd2Call![1] as string - expect(cmd2Content).toContain('# Command 2 Raw') - }) - - it('should not write files in dry run mode', async () => { - const dryRunContext = {...mockContext, dryRun: true} - vi.mocked(fs.writeFileSync).mockClear() - - const results = await plugin.writeProjectOutputs(dryRunContext) - - expect(fs.writeFileSync).not.toHaveBeenCalled() - expect(results.files.length).toBeGreaterThan(0) - expect(results.files.every(f => f.success)).toBe(true) - }) - - describe('mcp config merging', () => { - const skillWithMcp: any = { - dir: { - pathKind: FilePathKind.Relative, - path: 'mcp-skill', - basePath: projectBasePath, - getDirectoryName: () => 'mcp-skill', - getAbsolutePath: () => `${projectBasePath}/mcp-skill` - }, - content: '# MCP Skill', - yamlFrontMatter: {name: 'mcp-skill'}, - mcpConfig: { - type: 'SkillMcpConfig', - mcpServers: { - context7: {command: 'npx', args: ['-y', '@upstash/context7-mcp']}, - deepwiki: {url: 'https://mcp.deepwiki.com/mcp'} - }, - rawContent: '{"mcpServers":{}}' - } - } - - const skillWithoutMcp: any = { - dir: { - pathKind: FilePathKind.Relative, - path: 'normal-skill', - basePath: projectBasePath, - getDirectoryName: () => 'normal-skill', - getAbsolutePath: () => `${projectBasePath}/normal-skill` - }, - content: '# Normal Skill', - yamlFrontMatter: {name: 'normal-skill'} - } - - it('should register mcp_config.json when any skill has MCP config', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: []}, - skills: [skillWithMcp] - } - } as any - - const results = await plugin.registerProjectOutputFiles(ctx) - const mcpFile = results.find(r => r.path === 'mcp_config.json') - - expect(mcpFile).toBeDefined() - expect(mcpFile!.basePath.replaceAll('\\', '/')).toContain('.gemini/antigravity') - }) - - it('should NOT register mcp_config.json when no skill has MCP config', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: []}, - skills: [skillWithoutMcp] - } - } as any - - const results = await plugin.registerProjectOutputFiles(ctx) - const mcpFile = results.find(r => r.path === 'mcp_config.json') - - expect(mcpFile).toBeUndefined() - }) - - it('should write merged MCP config with correct format', async () => { - vi.mocked(fs.writeFileSync).mockClear() - - const ctx = { - collectedInputContext: { - globalMemory: null, - workspace: {projects: []}, - skills: [skillWithMcp], - fastCommands: null - }, - config: {plugins: []}, - dryRun: false - } as any - - await plugin.writeProjectOutputs(ctx) - - const mcpCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => - String(call[0]).replaceAll('\\', '/').includes('mcp_config.json')) - - expect(mcpCall).toBeDefined() - const content = JSON.parse(mcpCall![1] as string) - expect(content.mcpServers).toBeDefined() - expect(content.mcpServers.context7).toBeDefined() - expect(content.mcpServers.deepwiki).toBeDefined() - expect(content.mcpServers.deepwiki.serverUrl).toBe('https://mcp.deepwiki.com/mcp') - expect(content.mcpServers.deepwiki.url).toBeUndefined() - }) - - it('should skip writing mcp_config.json when no skill has MCP config', async () => { - vi.mocked(fs.writeFileSync).mockClear() - - const ctx = { - collectedInputContext: { - globalMemory: null, - workspace: {projects: []}, - skills: [skillWithoutMcp], - fastCommands: null - }, - config: {plugins: []}, - dryRun: false - } as any - - await plugin.writeProjectOutputs(ctx) - - const mcpCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => - String(call[0]).replaceAll('\\', '/').includes('mcp_config.json')) - - expect(mcpCall).toBeUndefined() - }) - - it('should not write mcp_config.json in dry-run mode', async () => { - vi.mocked(fs.writeFileSync).mockClear() - - const ctx = { - collectedInputContext: { - globalMemory: null, - workspace: {projects: []}, - skills: [skillWithMcp], - fastCommands: null - }, - config: {plugins: []}, - dryRun: true - } as any - - const results = await plugin.writeProjectOutputs(ctx) - - expect(fs.writeFileSync).not.toHaveBeenCalled() - const mcpResult = results.files.find(f => f.path.path === 'mcp_config.json') - expect(mcpResult).toBeDefined() - expect(mcpResult!.success).toBe(true) - }) - }) -}) diff --git a/packages/plugin-antigravity/src/AntigravityOutputPlugin.ts b/packages/plugin-antigravity/src/AntigravityOutputPlugin.ts deleted file mode 100644 index 1f274575..00000000 --- a/packages/plugin-antigravity/src/AntigravityOutputPlugin.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { - FastCommandPrompt, - OutputPluginContext, - OutputWriteContext, - SkillPrompt, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import * as os from 'node:os' -import * as path from 'node:path' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {PLUGIN_NAMES} from '@truenine/plugin-shared' - -const GLOBAL_CONFIG_DIR = '.agent' -const GLOBAL_GEMINI_DIR = '.gemini' -const ANTIGRAVITY_DIR = 'antigravity' -const SKILLS_SUBDIR = 'skills' -const WORKFLOWS_SUBDIR = 'workflows' -const MCP_CONFIG_FILE = 'mcp_config.json' -const CLEANUP_SUBDIRS = [SKILLS_SUBDIR, WORKFLOWS_SUBDIR] as const - -export class AntigravityOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('AntigravityOutputPlugin', { - globalConfigDir: GLOBAL_CONFIG_DIR, - outputFileName: '', - dependsOn: [PLUGIN_NAMES.GeminiCLIOutput] - }) - - this.registerCleanEffect('mcp-config-cleanup', async ctx => { - const mcpPath = path.join(this.getAntigravityDir(), MCP_CONFIG_FILE) - const content = JSON.stringify({mcpServers: {}}, null, 2) - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'mcpConfigCleanup', path: mcpPath}) - return {success: true, description: 'Would reset mcp_config.json'} - } - const result = await this.writeFile(ctx, mcpPath, content, 'mcpConfigCleanup') - if (result.success) return {success: true, description: 'Reset mcp_config.json'} - return {success: false, description: 'Failed', error: result.error ?? new Error('Cleanup failed')} - }) - } - - private getAntigravityDir(): string { - return path.join(os.homedir(), GLOBAL_GEMINI_DIR, ANTIGRAVITY_DIR) - } - - async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { - const {projects} = ctx.collectedInputContext.workspace - const results: RelativePath[] = [] - - for (const project of projects) { - if (project.dirFromWorkspacePath == null) continue - for (const subdir of CLEANUP_SUBDIRS) { - results.push(this.createRelativePath( - path.join(project.dirFromWorkspacePath.path, GLOBAL_CONFIG_DIR, subdir), - project.dirFromWorkspacePath.basePath, - () => subdir - )) - } - } - return results - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const {skills, fastCommands} = ctx.collectedInputContext - const baseDir = this.getAntigravityDir() - const results: RelativePath[] = [] - - if (skills != null) { - for (const skill of skills) { - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - const skillDir = path.join(baseDir, SKILLS_SUBDIR, skillName) - - results.push(this.createRelativePath('SKILL.md', skillDir, () => skillName)) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - results.push(this.createRelativePath( - refDoc.dir.path.replace(/\.mdx$/, '.md'), - skillDir, - () => skillName - )) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) results.push(this.createRelativePath(resource.relativePath, skillDir, () => skillName)) - } - } - } - - if (skills?.some(s => s.mcpConfig != null)) results.push(this.createRelativePath(MCP_CONFIG_FILE, baseDir, () => ANTIGRAVITY_DIR)) - - if (fastCommands == null) return results - - const transformOptions = this.getTransformOptionsFromContext(ctx) - const workflowsDir = path.join(baseDir, WORKFLOWS_SUBDIR) - for (const cmd of fastCommands) { - results.push(this.createRelativePath( - this.transformFastCommandName(cmd, transformOptions), - workflowsDir, - () => WORKFLOWS_SUBDIR - )) - } - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {fastCommands, skills} = ctx.collectedInputContext - if ((fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0) return true - this.log.trace({action: 'skip', reason: 'noOutputs'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const {fastCommands, skills} = ctx.collectedInputContext - const fileResults: WriteResult[] = [] - const baseDir = this.getAntigravityDir() - - if (fastCommands != null) { - const workflowsDir = path.join(baseDir, WORKFLOWS_SUBDIR) - for (const cmd of fastCommands) fileResults.push(await this.writeFastCommand(ctx, workflowsDir, cmd)) - } - - if (skills != null) { - const skillsDir = path.join(baseDir, SKILLS_SUBDIR) - for (const skill of skills) fileResults.push(...await this.writeSkill(ctx, skillsDir, skill)) - const mcpResult = await this.writeGlobalMcpConfig(ctx, baseDir, skills) - if (mcpResult != null) fileResults.push(mcpResult) - } - - this.log.info({action: 'write', message: `Synced ${fileResults.length} files`, globalDir: baseDir}) - return {files: fileResults, dirs: []} - } - - private async writeGlobalMcpConfig( - ctx: OutputWriteContext, - baseDir: string, - skills: readonly SkillPrompt[] - ): Promise { - const mergedServers: Record = {} - - for (const skill of skills) { - if (skill.mcpConfig == null) continue - for (const [name, config] of Object.entries(skill.mcpConfig.mcpServers)) { - mergedServers[name] = this.transformMcpConfig(config as unknown as Record) - } - } - - if (Object.keys(mergedServers).length === 0) return null - - const fullPath = path.join(baseDir, MCP_CONFIG_FILE) - const content = JSON.stringify({mcpServers: mergedServers}, null, 2) - return this.writeFile(ctx, fullPath, content, 'globalMcpConfig') - } - - private transformMcpConfig(config: Record): Record { - const result: Record = {} - for (const [key, value] of Object.entries(config)) { - if (key === 'url') result['serverUrl'] = value - else if (key === 'type' || key === 'enabled' || key === 'autoApprove') continue - else result[key] = value - } - return result - } - - private async writeFastCommand( - ctx: OutputWriteContext, - targetDir: string, - cmd: FastCommandPrompt - ): Promise { - const transformOptions = this.getTransformOptionsFromContext(ctx) - const fileName = this.transformFastCommandName(cmd, transformOptions) - const fullPath = path.join(targetDir, fileName) - - const filteredFm: {description?: string} = typeof cmd.yamlFrontMatter?.description === 'string' - ? {description: cmd.yamlFrontMatter.description} - : {} - - let content: string - if (cmd.rawMdxContent != null) { - const body = cmd.rawMdxContent.replace(/^---\n[\s\S]*?\n---\n/, '') - content = this.buildMarkdownContentWithRaw(body, filteredFm, cmd.rawFrontMatter) - } else content = this.buildMarkdownContentWithRaw(cmd.content, filteredFm, cmd.rawFrontMatter) - - return this.writeFile(ctx, fullPath, content, 'fastCommand') - } - - private async writeSkill( - ctx: OutputWriteContext, - targetBaseDir: string, - skill: SkillPrompt - ): Promise { - const results: WriteResult[] = [] - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - const skillDir = path.join(targetBaseDir, skillName) - const skillPath = path.join(skillDir, 'SKILL.md') - - const content = this.buildMarkdownContentWithRaw(skill.content as string, skill.yamlFrontMatter, skill.rawFrontMatter) - results.push(await this.writeFile(ctx, skillPath, content, 'skill')) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') - results.push(await this.writeFile(ctx, path.join(skillDir, fileName), refDoc.content as string, 'skillRefDoc')) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) results.push(await this.writeFile(ctx, path.join(skillDir, resource.relativePath), resource.content, 'skillResource')) - } - - return results - } -} diff --git a/packages/plugin-antigravity/src/index.ts b/packages/plugin-antigravity/src/index.ts deleted file mode 100644 index 784b1336..00000000 --- a/packages/plugin-antigravity/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - AntigravityOutputPlugin -} from './AntigravityOutputPlugin' diff --git a/packages/plugin-antigravity/tsconfig.eslint.json b/packages/plugin-antigravity/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-antigravity/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-antigravity/tsconfig.json b/packages/plugin-antigravity/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-antigravity/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-antigravity/tsconfig.lib.json b/packages/plugin-antigravity/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-antigravity/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-antigravity/tsconfig.test.json b/packages/plugin-antigravity/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-antigravity/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-antigravity/tsdown.config.ts b/packages/plugin-antigravity/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-antigravity/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-antigravity/vite.config.ts b/packages/plugin-antigravity/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-antigravity/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-antigravity/vitest.config.ts b/packages/plugin-antigravity/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-antigravity/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-claude-code-cli/eslint.config.ts b/packages/plugin-claude-code-cli/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-claude-code-cli/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-claude-code-cli/package.json b/packages/plugin-claude-code-cli/package.json deleted file mode 100644 index 3894daf8..00000000 --- a/packages/plugin-claude-code-cli/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-claude-code-cli", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Claude Code CLI output plugin", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts b/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts deleted file mode 100644 index 3d20ad39..00000000 --- a/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import type {OutputPluginContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' - -class TestableClaudeCodeCLIOutputPlugin extends ClaudeCodeCLIOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -function createMockContext( - tempDir: string, - rules: unknown[], - projects: unknown[] -): OutputPluginContext { - return { - collectedInputContext: { - workspace: { - projects: projects as never, - directory: { - pathKind: 1, - path: tempDir, - basePath: tempDir, - getDirectoryName: () => 'workspace', - getAbsolutePath: () => tempDir - } - }, - ideConfigFiles: [], - rules: rules as never, - fastCommands: [], - skills: [], - globalMemory: void 0, - aiAgentIgnoreConfigFiles: [], - subAgents: [] - }, - logger: { - debug: vi.fn(), - trace: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } as never, - fs, - path, - glob: vi.fn() as never - } -} - -describe('claudeCodeCLIOutputPlugin - projectConfig filtering', () => { - let tempDir: string, - plugin: TestableClaudeCodeCLIOutputPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-proj-config-test-')) - plugin = new TestableClaudeCodeCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch {} - }) - - describe('registerProjectOutputFiles', () => { - it('should include all project rules when no projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [createMockProject('proj1', tempDir, 'proj1')] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).toContain('rule-test-rule2.md') - }) - - it('should filter rules by include in projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).not.toContain('rule-test-rule2.md') - }) - - it('should filter rules by includeSeries excluding non-matching series', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).not.toContain('rule-test-rule1.md') - expect(fileNames).toContain('rule-test-rule2.md') - }) - - it('should include rules without seriName regardless of include filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', void 0, 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).not.toContain('rule-test-rule2.md') - }) - - it('should filter independently for each project', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}), - createMockProject('proj2', tempDir, 'proj2', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = results.map(r => ({ - path: r.path, - fileName: r.path.split(/[/\\]/).pop() - })) - - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule1.md')).toBe(true) - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule2.md')).toBe(false) - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule2.md')).toBe(true) - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule1.md')).toBe(false) - }) - - it('should return empty when include matches nothing', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const ruleFiles = results.filter(r => r.path.includes('rule-')) - - expect(ruleFiles).toHaveLength(0) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should not register rules dir when all rules filtered out', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputDirs(ctx) - const rulesDirs = results.filter(r => r.path.includes('rules')) - - expect(rulesDirs).toHaveLength(0) - }) - - it('should register rules dir when rules match filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputDirs(ctx) - const rulesDirs = results.filter(r => r.path.includes('rules')) - - expect(rulesDirs.length).toBeGreaterThan(0) - }) - }) -}) diff --git a/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.property.test.ts b/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.property.test.ts deleted file mode 100644 index bcc4d54c..00000000 --- a/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.property.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type {CollectedInputContext, OutputPluginContext, Project, RelativePath, RootPath, RulePrompt} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return {pathKind: FilePathKind.Relative, path: pathStr, basePath, getDirectoryName: () => pathStr, getAbsolutePath: () => path.join(basePath, pathStr)} -} - -class TestablePlugin extends ClaudeCodeCLIOutputPlugin { - private mockHomeDir: string | null = null - public setMockHomeDir(dir: string | null): void { this.mockHomeDir = dir } - protected override getHomeDir(): string { return this.mockHomeDir ?? super.getHomeDir() } - public testBuildRuleFileName(rule: RulePrompt): string { return (this as any).buildRuleFileName(rule) } - public testBuildRuleContent(rule: RulePrompt): string { return (this as any).buildRuleContent(rule) } -} - -function createMockRulePrompt(opts: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { - const content = opts.content ?? '# Rule body' - return {type: PromptKind.Rule, content, length: content.length, filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', ''), markdownContents: [], yamlFrontMatter: {description: 'ignored', globs: opts.globs}, series: opts.series, ruleName: opts.ruleName, globs: opts.globs, scope: opts.scope ?? 'global'} as RulePrompt -} - -const seriesGen = fc.stringMatching(/^[a-z0-9]{1,5}$/) -const ruleNameGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,14}$/) -const globGen = fc.stringMatching(/^[a-z*/.]{1,30}$/).filter(s => s.length > 0) -const globsGen = fc.array(globGen, {minLength: 1, maxLength: 5}) -const contentGen = fc.string({minLength: 1, maxLength: 200}).filter(s => s.trim().length > 0) - -describe('claudeCodeCLIOutputPlugin property tests', () => { - let tempDir: string, plugin: TestablePlugin, mockContext: OutputPluginContext - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-prop-')) - plugin = new TestablePlugin() - plugin.setMockHomeDir(tempDir) - mockContext = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: {type: PromptKind.GlobalMemory, content: 'mem', filePathKind: FilePathKind.Absolute, dir: createMockRelativePath('.', tempDir), markdownContents: []}, - fastCommands: [], - subAgents: [], - skills: [] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - }, 30000) - - afterEach(() => { - try { fs.rmSync(tempDir, {recursive: true, force: true}) } - catch {} - }) - - describe('rule file name format', () => { - it('should always produce rule-{series}-{ruleName}.md', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, async (series, ruleName) => { - const rule = createMockRulePrompt({series, ruleName, globs: []}) - const fileName = plugin.testBuildRuleFileName(rule) - expect(fileName).toBe(`rule-${series}-${ruleName}.md`) - expect(fileName).toMatch(/^rule-.[^-\n\r\u2028\u2029]*-.+\.md$/) - }), {numRuns: 100}) - }) - }) - - describe('rule content format constraints', () => { - it('should never contain globs field in frontmatter', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).not.toMatch(/^globs:/m) - }), {numRuns: 100}) - }) - - it('should use paths field when globs are present', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).toContain('paths:') - }), {numRuns: 100}) - }) - - it('should wrap frontmatter in --- delimiters when globs exist', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - const lines = output.split('\n') - expect(lines[0]).toBe('---') - expect(lines.indexOf('---', 1)).toBeGreaterThan(0) - }), {numRuns: 100}) - }) - - it('should have no frontmatter when globs are empty', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, contentGen, async (series, ruleName, content) => { - const rule = createMockRulePrompt({series, ruleName, globs: [], content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).not.toContain('---') - expect(output).toBe(content) - }), {numRuns: 100}) - }) - - it('should preserve rule body content after frontmatter', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).toContain(content) - }), {numRuns: 100}) - }) - - it('should list each glob as a YAML array item under paths', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - for (const g of globs) expect(output).toContain(`- "${g}"`) - }), {numRuns: 100}) - }) - }) - - describe('write output format verification', () => { - it('should write global rule files with correct format to ~/.claude/rules/', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, scope: 'global', content}) - const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, rules: [rule]}} as any - await plugin.writeGlobalOutputs(ctx) - const filePath = path.join(tempDir, '.claude', 'rules', `rule-${series}-${ruleName}.md`) - expect(fs.existsSync(filePath)).toBe(true) - const written = fs.readFileSync(filePath, 'utf8') - expect(written).toContain('paths:') - expect(written).not.toMatch(/^globs:/m) - expect(written).toContain(content) - for (const g of globs) expect(written).toContain(`- "${g}"`) - }), {numRuns: 30}) - }) - - it('should write project rule files to {project}/.claude/rules/', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const mockProject: Project = { - name: 'proj', - dirFromWorkspacePath: createMockRelativePath('proj', tempDir), - rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Root, dir: createMockRelativePath('.', tempDir) as unknown as RootPath, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}}, - childMemoryPrompts: [], - sourceFiles: [] - } - const rule = createMockRulePrompt({series, ruleName, globs, scope: 'project', content}) - const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, rules: [rule]}} as any - await plugin.writeProjectOutputs(ctx) - const filePath = path.join(tempDir, 'proj', '.claude', 'rules', `rule-${series}-${ruleName}.md`) - expect(fs.existsSync(filePath)).toBe(true) - const written = fs.readFileSync(filePath, 'utf8') - expect(written).toContain('paths:') - expect(written).toContain(content) - for (const g of globs) expect(written).toContain(`- "${g}"`) - }), {numRuns: 30}) - }) - }) -}) diff --git a/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.test.ts b/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.test.ts deleted file mode 100644 index cfd70e05..00000000 --- a/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import type {CollectedInputContext, FastCommandPrompt, OutputPluginContext, Project, RelativePath, RootPath, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { // Helper to create mock RelativePath - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -class TestableClaudeCodeCLIOutputPlugin extends ClaudeCodeCLIOutputPlugin { // Testable subclass to mock home dir - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } - - public testBuildRuleFileName(rule: RulePrompt): string { - return (this as any).buildRuleFileName(rule) - } - - public testBuildRuleContent(rule: RulePrompt): string { - return (this as any).buildRuleContent(rule) - } -} - -function createMockRulePrompt(options: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { - const content = options.content ?? '# Rule body' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', ''), - markdownContents: [], - yamlFrontMatter: {description: 'ignored', globs: options.globs}, - series: options.series, - ruleName: options.ruleName, - globs: options.globs, - scope: options.scope ?? 'global' - } as RulePrompt -} - -describe('claudeCodeCLIOutputPlugin', () => { - let tempDir: string, - plugin: TestableClaudeCodeCLIOutputPlugin, - mockContext: OutputPluginContext - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-')) - plugin = new TestableClaudeCodeCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - mockContext = { - collectedInputContext: { - workspace: { - projects: [], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: { - type: PromptKind.GlobalMemory, - content: 'Global Memory Content', - filePathKind: FilePathKind.Absolute, - dir: createMockRelativePath('.', tempDir), - markdownContents: [] - }, - fastCommands: [], - subAgents: [], - skills: [] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - }, 30000) - - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch { - } // ignore cleanup errors - } - }) - - describe('registerGlobalOutputDirs', () => { - it('should register commands, agents, and skills subdirectories in .claude', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - - const dirPaths = dirs.map(d => d.path) - expect(dirPaths).toContain('commands') - expect(dirPaths).toContain('agents') - expect(dirPaths).toContain('skills') - - const expectedBasePath = path.join(tempDir, '.claude') - dirs.forEach(d => expect(d.basePath).toBe(expectedBasePath)) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should register project cleanup directories', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - rootMemoryPrompt: { - type: PromptKind.ProjectRootMemory, - content: 'content', - filePathKind: FilePathKind.Root, - dir: createMockRelativePath('.', tempDir) as unknown as RootPath, - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} - }, - childMemoryPrompts: [], - sourceFiles: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const dirs = await plugin.registerProjectOutputDirs(ctxWithProject) - const dirPaths = dirs.map(d => d.path) // (Or possibly more if logic changed, but based on code, it loops subdirs) // Expect 3 dirs: .claude/commands, .claude/agents, .claude/skills - - expect(dirPaths.some(p => p.includes(path.join('.claude', 'commands')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.claude', 'agents')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.claude', 'skills')))).toBe(true) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register CLAUDE.md in global config dir', async () => { - const files = await plugin.registerGlobalOutputFiles(mockContext) - const outputFile = files.find(f => f.path === 'CLAUDE.md') - expect(outputFile).toBeDefined() - expect(outputFile?.basePath).toBe(path.join(tempDir, '.claude')) - }) - - it('should register fast commands in commands subdirectory', async () => { - const mockCmd: FastCommandPrompt = { - type: PromptKind.FastCommand, - commandName: 'test-cmd', - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-cmd', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, description: 'desc'} - } - - const ctxWithCmd = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - fastCommands: [mockCmd] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithCmd) - const cmdFile = files.find(f => f.path.includes('test-cmd.md')) - - expect(cmdFile).toBeDefined() - expect(cmdFile?.path).toContain('commands') - expect(cmdFile?.basePath).toBe(path.join(tempDir, '.claude')) - }) - - it('should register sub agents in agents subdirectory', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-agent.md', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'agent', description: 'desc'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('test-agent.md')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('agents') - expect(agentFile?.basePath).toBe(path.join(tempDir, '.claude')) - }) - - it('should strip .mdx suffix from sub agent path and use .md', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'agent content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('code-review.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'desc'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('agents')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('code-review.cn.md') - expect(agentFile?.path).not.toContain('.mdx') - }) - - it('should register skills in skills subdirectory', 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'} - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) - const skillFile = files.find(f => f.path.includes('SKILL.md')) - - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain('skills') - expect(skillFile?.basePath).toBe(path.join(tempDir, '.claude')) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should only register project CLAUDE.md files', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - rootMemoryPrompt: { - type: PromptKind.ProjectRootMemory, - content: 'content', - filePathKind: FilePathKind.Root, - dir: createMockRelativePath('.', tempDir) as unknown as RootPath, - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} - }, - childMemoryPrompts: [], - sourceFiles: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const files = await plugin.registerProjectOutputFiles(ctxWithProject) - - expect(files).toHaveLength(1) - expect(files[0].path).toBe(path.join('project-a', 'CLAUDE.md')) - expect(files[0].basePath).toBe(tempDir) - }) - }) - - describe('writeGlobalOutputs', () => { - it('should write sub agent file with .md extension when source has .mdx', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: '# Code Review Agent', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('reviewer.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'reviewer', description: 'desc'} - } - - const writeCtx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const results = await plugin.writeGlobalOutputs(writeCtx) - const agentResult = results.files.find(f => f.path.path === 'reviewer.cn.md') - - expect(agentResult).toBeDefined() - expect(agentResult?.success).toBe(true) - - const writtenPath = path.join(tempDir, '.claude', 'agents', 'reviewer.cn.md') - expect(fs.existsSync(writtenPath)).toBe(true) - expect(fs.existsSync(path.join(tempDir, '.claude', 'agents', 'reviewer.cn.mdx'))).toBe(false) - expect(fs.existsSync(path.join(tempDir, '.claude', 'agents', 'reviewer.cn.mdx.md'))).toBe(false) - }) - }) - - describe('buildRuleFileName', () => { - it('should produce rule-{series}-{ruleName}.md', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'naming', globs: []}) - expect(plugin.testBuildRuleFileName(rule)).toBe('rule-01-naming.md') - }) - }) - - describe('buildRuleContent', () => { - it('should return plain content when globs is empty', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: [], content: '# No globs'}) - expect(plugin.testBuildRuleContent(rule)).toBe('# No globs') - }) - - it('should use paths field (not globs) in YAML frontmatter per Claude Code docs', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], content: '# TS rule'}) - const content = plugin.testBuildRuleContent(rule) - expect(content).toContain('paths:') - expect(content).not.toMatch(/^globs:/m) - }) - - it('should output paths as YAML array items', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts', '**/*.tsx'], content: '# Body'}) - const content = plugin.testBuildRuleContent(rule) - expect(content).toContain('- "**/*.ts"') - expect(content).toContain('- "**/*.tsx"') - }) - - it('should double-quote paths that do not start with *', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['src/components/*.tsx', 'lib/utils.ts'], content: '# Body'}) - const content = plugin.testBuildRuleContent(rule) - expect(content).toContain('- "src/components/*.tsx"') - expect(content).toContain('- "lib/utils.ts"') - }) - - it('should preserve rule body after frontmatter', () => { - const body = '# My Rule\n\nSome content.' - const rule = createMockRulePrompt({series: '01', ruleName: 'x', globs: ['*.ts'], content: body}) - const content = plugin.testBuildRuleContent(rule) - expect(content).toContain(body) - }) - - it('should wrap content in valid YAML frontmatter delimiters', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'x', globs: ['*.ts'], content: '# Body'}) - const content = plugin.testBuildRuleContent(rule) - const lines = content.split('\n') - expect(lines[0]).toBe('---') - expect(lines.indexOf('---', 1)).toBeGreaterThan(0) - }) - }) - - describe('rules registration', () => { - it('should register rules subdir in global output dirs when global rules exist', async () => { - const ctx = { - ...mockContext, - collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} - } - const dirs = await plugin.registerGlobalOutputDirs(ctx) - expect(dirs.map(d => d.path)).toContain('rules') - }) - - it('should not register rules subdir when no global rules', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - expect(dirs.map(d => d.path)).not.toContain('rules') - }) - - it('should register global rule files in ~/.claude/rules/', async () => { - const ctx = { - ...mockContext, - collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} - } - const files = await plugin.registerGlobalOutputFiles(ctx) - const ruleFile = files.find(f => f.path === 'rule-01-ts.md') - expect(ruleFile).toBeDefined() - expect(ruleFile?.basePath).toBe(path.join(tempDir, '.claude', 'rules')) - }) - - it('should not register project rules as global files', async () => { - const ctx = { - ...mockContext, - collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'project'})]} - } - const files = await plugin.registerGlobalOutputFiles(ctx) - expect(files.find(f => f.path.includes('rule-'))).toBeUndefined() - }) - }) - - describe('canWrite with rules', () => { - it('should return true when rules exist even without other content', async () => { - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - globalMemory: void 0, - rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: []})] - } - } - expect(await plugin.canWrite(ctx as any)).toBe(true) - }) - }) - - describe('writeGlobalOutputs with rules', () => { - it('should write global rule file to ~/.claude/rules/', async () => { - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global', content: '# TS rule'})] - } - } - const results = await plugin.writeGlobalOutputs(ctx as any) - const ruleResult = results.files.find(f => f.path.path === 'rule-01-ts.md') - expect(ruleResult?.success).toBe(true) - - const filePath = path.join(tempDir, '.claude', 'rules', 'rule-01-ts.md') - expect(fs.existsSync(filePath)).toBe(true) - const content = fs.readFileSync(filePath, 'utf8') - expect(content).toContain('paths:') - expect(content).toContain('# TS rule') - }) - - it('should write rule without frontmatter when globs is empty', async () => { - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - rules: [createMockRulePrompt({series: '01', ruleName: 'general', globs: [], scope: 'global', content: '# Always apply'})] - } - } - await plugin.writeGlobalOutputs(ctx as any) - const filePath = path.join(tempDir, '.claude', 'rules', 'rule-01-general.md') - const content = fs.readFileSync(filePath, 'utf8') - expect(content).toBe('# Always apply') - expect(content).not.toContain('---') - }) - }) - - describe('writeProjectOutputs with rules', () => { - it('should write project rule file to {project}/.claude/rules/', async () => { - const mockProject: Project = { - name: 'proj', - dirFromWorkspacePath: createMockRelativePath('proj', tempDir), - rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Root, dir: createMockRelativePath('.', tempDir) as unknown as RootPath, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}}, - childMemoryPrompts: [], - sourceFiles: [] - } - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, - rules: [createMockRulePrompt({series: '02', ruleName: 'api', globs: ['src/api/**'], scope: 'project', content: '# API rules'})] - } - } - const results = await plugin.writeProjectOutputs(ctx as any) - expect(results.files.some(f => f.path.path === 'rule-02-api.md' && f.success)).toBe(true) - - const filePath = path.join(tempDir, 'proj', '.claude', 'rules', 'rule-02-api.md') - expect(fs.existsSync(filePath)).toBe(true) - const content = fs.readFileSync(filePath, 'utf8') - expect(content).toContain('paths:') - expect(content).toContain('# API rules') - }) - }) -}) diff --git a/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.ts b/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.ts deleted file mode 100644 index 52b6de9b..00000000 --- a/packages/plugin-claude-code-cli/src/ClaudeCodeCLIOutputPlugin.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type {OutputPluginContext, OutputWriteContext, RulePrompt, WriteResults} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import * as path from 'node:path' -import {buildMarkdownWithFrontMatter, doubleQuoted} from '@truenine/md-compiler/markdown' -import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterRulesByProjectConfig} from '@truenine/plugin-output-shared/utils' - -const PROJECT_MEMORY_FILE = 'CLAUDE.md' -const GLOBAL_CONFIG_DIR = '.claude' -const RULES_SUBDIR = 'rules' -const RULE_FILE_PREFIX = 'rule-' - -/** - * Output plugin for Claude Code CLI. - * - * Outputs rules to `.claude/rules/` directory with frontmatter format. - * - * @see https://github.com/anthropics/claude-code/issues/26868 - * Known bug: Claude Code CLI has issues with `.claude/rules` directory handling. - * This may affect rule loading behavior in certain scenarios. - */ -export class ClaudeCodeCLIOutputPlugin extends BaseCLIOutputPlugin { - constructor() { - super('ClaudeCodeCLIOutputPlugin', { - globalConfigDir: GLOBAL_CONFIG_DIR, - outputFileName: PROJECT_MEMORY_FILE, - toolPreset: 'claudeCode', - supportsFastCommands: true, - supportsSubAgents: true, - supportsSkills: true - }) - } - - private buildRuleFileName(rule: RulePrompt): string { - return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` - } - - private buildRuleContent(rule: RulePrompt): string { - if (rule.globs.length === 0) return rule.content - return buildMarkdownWithFrontMatter({paths: rule.globs.map(doubleQuoted)}, rule.content) - } - - override async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { - const results = await super.registerGlobalOutputDirs(ctx) - const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') - if (globalRules != null && globalRules.length > 0) results.push(this.createRelativePath(RULES_SUBDIR, this.getGlobalConfigDir(), () => RULES_SUBDIR)) - return results - } - - override async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const results = await super.registerGlobalOutputFiles(ctx) - const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') - if (globalRules == null || globalRules.length === 0) return results - const rulesDir = path.join(this.getGlobalConfigDir(), RULES_SUBDIR) - for (const rule of globalRules) results.push(this.createRelativePath(this.buildRuleFileName(rule), rulesDir, () => RULES_SUBDIR)) - return results - } - - override async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { - const results = await super.registerProjectOutputDirs(ctx) - const {rules} = ctx.collectedInputContext - if (rules == null || rules.length === 0) return results - for (const project of ctx.collectedInputContext.workspace.projects) { - if (project.dirFromWorkspacePath == null) continue - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => this.normalizeRuleScope(r) === 'project'), - project.projectConfig - ), - project.projectConfig - ) - if (projectRules.length === 0) continue - const dirPath = path.join(project.dirFromWorkspacePath.path, this.globalConfigDir, RULES_SUBDIR) - results.push(this.createRelativePath(dirPath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) - } - return results - } - - override async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results = await super.registerProjectOutputFiles(ctx) - const {rules} = ctx.collectedInputContext - if (rules == null || rules.length === 0) return results - for (const project of ctx.collectedInputContext.workspace.projects) { - if (project.dirFromWorkspacePath == null) continue - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => this.normalizeRuleScope(r) === 'project'), - project.projectConfig - ), - project.projectConfig - ) - for (const rule of projectRules) { - const filePath = path.join(project.dirFromWorkspacePath.path, this.globalConfigDir, RULES_SUBDIR, this.buildRuleFileName(rule)) - results.push(this.createRelativePath(filePath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) - } - } - return results - } - - override async canWrite(ctx: OutputWriteContext): Promise { - if ((ctx.collectedInputContext.rules?.length ?? 0) > 0) return true - return super.canWrite(ctx) - } - - override async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const results = await super.writeGlobalOutputs(ctx) - const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') - if (globalRules == null || globalRules.length === 0) return results - const rulesDir = path.join(this.getGlobalConfigDir(), RULES_SUBDIR) - const ruleResults = [] - for (const rule of globalRules) ruleResults.push(await this.writeFile(ctx, path.join(rulesDir, this.buildRuleFileName(rule)), this.buildRuleContent(rule), 'rule')) - return {files: [...results.files, ...ruleResults], dirs: results.dirs} - } - - override async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const results = await super.writeProjectOutputs(ctx) - const {rules} = ctx.collectedInputContext - if (rules == null || rules.length === 0) return results - const ruleResults = [] - for (const project of ctx.collectedInputContext.workspace.projects) { - if (project.dirFromWorkspacePath == null) continue - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => this.normalizeRuleScope(r) === 'project'), - project.projectConfig - ), - project.projectConfig - ) - if (projectRules.length === 0) continue - const rulesDir = path.join(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path, this.globalConfigDir, RULES_SUBDIR) - for (const rule of projectRules) ruleResults.push(await this.writeFile(ctx, path.join(rulesDir, this.buildRuleFileName(rule)), this.buildRuleContent(rule), 'rule')) - } - return {files: [...results.files, ...ruleResults], dirs: results.dirs} - } -} diff --git a/packages/plugin-claude-code-cli/src/index.ts b/packages/plugin-claude-code-cli/src/index.ts deleted file mode 100644 index e65d3791..00000000 --- a/packages/plugin-claude-code-cli/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - ClaudeCodeCLIOutputPlugin -} from './ClaudeCodeCLIOutputPlugin' diff --git a/packages/plugin-claude-code-cli/tsconfig.eslint.json b/packages/plugin-claude-code-cli/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-claude-code-cli/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-claude-code-cli/tsconfig.json b/packages/plugin-claude-code-cli/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-claude-code-cli/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-claude-code-cli/tsconfig.lib.json b/packages/plugin-claude-code-cli/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-claude-code-cli/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-claude-code-cli/tsconfig.test.json b/packages/plugin-claude-code-cli/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-claude-code-cli/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-claude-code-cli/tsdown.config.ts b/packages/plugin-claude-code-cli/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-claude-code-cli/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-claude-code-cli/vite.config.ts b/packages/plugin-claude-code-cli/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-claude-code-cli/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-claude-code-cli/vitest.config.ts b/packages/plugin-claude-code-cli/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-claude-code-cli/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-cursor/eslint.config.ts b/packages/plugin-cursor/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-cursor/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-cursor/package.json b/packages/plugin-cursor/package.json deleted file mode 100644 index 8854dfee..00000000 --- a/packages/plugin-cursor/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@truenine/plugin-cursor", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Cursor IDE output plugin", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "picomatch": "catalog:" - } -} diff --git a/packages/plugin-cursor/src/CursorOutputPlugin.projectConfig.test.ts b/packages/plugin-cursor/src/CursorOutputPlugin.projectConfig.test.ts deleted file mode 100644 index e224b5c2..00000000 --- a/packages/plugin-cursor/src/CursorOutputPlugin.projectConfig.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import type {OutputPluginContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {CursorOutputPlugin} from './CursorOutputPlugin' - -class TestableCursorOutputPlugin extends CursorOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -function createMockContext( - tempDir: string, - rules: unknown[], - projects: unknown[] -): OutputPluginContext { - return { - collectedInputContext: { - workspace: { - projects: projects as never, - directory: { - pathKind: 1, - path: tempDir, - basePath: tempDir, - getDirectoryName: () => 'workspace', - getAbsolutePath: () => tempDir - } - }, - ideConfigFiles: [], - rules: rules as never, - fastCommands: [], - skills: [], - globalMemory: void 0, - aiAgentIgnoreConfigFiles: [] - }, - logger: { - debug: vi.fn(), - trace: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } as never, - fs, - path, - glob: vi.fn() as never - } -} - -describe('cursorOutputPlugin - projectConfig filtering', () => { - let tempDir: string, - plugin: TestableCursorOutputPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-proj-config-test-')) - plugin = new TestableCursorOutputPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch {} - }) - - describe('registerProjectOutputFiles', () => { - it('should include all project rules when no projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [createMockProject('proj1', tempDir, 'proj1')] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.mdc') - expect(fileNames).toContain('rule-test-rule2.mdc') - }) - - it('should filter rules by include in projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.mdc') - expect(fileNames).not.toContain('rule-test-rule2.mdc') - }) - - it('should filter rules by includeSeries excluding non-matching series', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).not.toContain('rule-test-rule1.mdc') - expect(fileNames).toContain('rule-test-rule2.mdc') - }) - - it('should include rules without seriName regardless of include filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', void 0, 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.mdc') - expect(fileNames).not.toContain('rule-test-rule2.mdc') - }) - - it('should filter independently for each project', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}), - createMockProject('proj2', tempDir, 'proj2', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = results.map(r => ({ - path: r.path, - fileName: r.path.split(/[/\\]/).pop() - })) - - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule1.mdc')).toBe(true) // proj1 should have rule1 - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule2.mdc')).toBe(false) - - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule2.mdc')).toBe(true) // proj2 should have rule2 - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule1.mdc')).toBe(false) - }) - - it('should return empty when include matches nothing', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const ruleFiles = results.filter(r => r.path.includes('rule-')) - - expect(ruleFiles).toHaveLength(0) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should register rules dir when project rules exist (directory registration is pre-filter)', async () => { - const rules = [ // The actual filtering happens in registerProjectOutputFiles and writeProjectOutputs // Note: registerProjectOutputDirs registers directories if any project rules exist - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputDirs(ctx) - const rulesDirs = results.filter(r => r.path.includes('rules')) - - expect(rulesDirs.length).toBeGreaterThan(0) // Directory is registered because rules exist (even if filtered out later) - }) - - it('should register rules dir when rules match filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputDirs(ctx) - const rulesDirs = results.filter(r => r.path.includes('rules')) - - expect(rulesDirs.length).toBeGreaterThan(0) - }) - }) -}) diff --git a/packages/plugin-cursor/src/CursorOutputPlugin.test.ts b/packages/plugin-cursor/src/CursorOutputPlugin.test.ts deleted file mode 100644 index e0ea8445..00000000 --- a/packages/plugin-cursor/src/CursorOutputPlugin.test.ts +++ /dev/null @@ -1,833 +0,0 @@ -import type { - FastCommandPrompt, - GlobalMemoryPrompt, - OutputPluginContext, - OutputWriteContext, - RelativePath, - RulePrompt -} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {createLogger, FilePathKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it} from 'vitest' -import {CursorOutputPlugin} from './CursorOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -function createMockGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt { - return { - type: PromptKind.GlobalMemory, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', basePath), - markdownContents: [] - } as GlobalMemoryPrompt -} - -function createMockFastCommandPrompt( - commandName: string, - series?: string, - basePath = '' -): FastCommandPrompt { - const content = 'Run something' - return { - type: PromptKind.FastCommand, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', basePath), - markdownContents: [], - yamlFrontMatter: {description: 'Fast command'}, - ...series != null && {series}, - commandName - } as FastCommandPrompt -} - -function createMockSkillPrompt( - name: string, - content = '# Skill', - basePath = '', - options?: {mcpConfig?: unknown} -) { - return { - yamlFrontMatter: {name, description: 'A skill'}, - dir: createMockRelativePath(name, basePath), - content, - length: content.length, - type: PromptKind.Skill, - filePathKind: FilePathKind.Relative, - markdownContents: [], - ...options - } -} - -class TestableCursorOutputPlugin extends CursorOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } - - public buildRuleMdcContentForTest(rule: RulePrompt): string { - return this.buildRuleMdcContent(rule) - } -} - -function createMockRulePrompt( - options: {series: string, ruleName: string, globs: readonly string[], content?: string} -): RulePrompt { - const content = options.content ?? '# Rule body' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', ''), - markdownContents: [], - yamlFrontMatter: {description: 'ignored', globs: options.globs}, - series: options.series, - ruleName: options.ruleName, - globs: options.globs, - scope: 'global' - } as RulePrompt -} - -describe('cursor output plugin', () => { - let tempDir: string, plugin: TestableCursorOutputPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-mcp-test-')) - plugin = new TestableCursorOutputPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - if (tempDir != null && fs.existsSync(tempDir)) { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch { // ignore cleanup errors - } - } - }) - - describe('constructor', () => { - it('should have correct plugin name', () => expect(plugin.name).toBe('CursorOutputPlugin')) - - it('should depend on AgentsOutputPlugin', () => expect(plugin.dependsOn).toContain('AgentsOutputPlugin')) - }) - - describe('buildRuleMdcContent (Cursor rules front matter)', () => { - it('should output only alwaysApply and globs in front matter', () => { - const rule = createMockRulePrompt({ - series: 'cursor', - ruleName: 'ts', - globs: ['**/*.ts'], - content: '# TypeScript rule' - }) - const raw = plugin.buildRuleMdcContentForTest(rule) - const lines = raw.split('\n') - const start = lines.indexOf('---') - const end = lines.indexOf('---', start + 1) - expect(start).toBeGreaterThanOrEqual(0) - expect(end).toBeGreaterThan(start) - const fmLines = lines.slice(start + 1, end).filter(l => l.trim().length > 0) - const keys = fmLines.map(l => l.split(':')[0]!.trim()).sort() - expect(keys).toEqual(['alwaysApply', 'globs']) - }) - - it('should set alwaysApply to false', () => { - const rule = createMockRulePrompt({ - series: 'cursor', - ruleName: 'ts', - globs: ['**/*.ts'], - content: '# Body' - }) - const raw = plugin.buildRuleMdcContentForTest(rule) - const lines = raw.split('\n') - const fmLine = lines.find(l => l.trimStart().startsWith('alwaysApply:')) - expect(fmLine).toBeDefined() - expect(fmLine).toBe('alwaysApply: false') - }) - - it('should output globs as comma-separated string, not YAML array', () => { - const rule = createMockRulePrompt({ - series: 'cursor', - ruleName: 'ts', - globs: ['**/*.ts', '**/*.tsx'], - content: '# Body' - }) - const raw = plugin.buildRuleMdcContentForTest(rule) - const lines = raw.split('\n') - const globsLine = lines.find(l => l.trimStart().startsWith('globs:')) - expect(globsLine).toBeDefined() - expect(globsLine).toBe('globs: **/*.ts, **/*.tsx') - }) - - it('should output single glob as string without trailing comma', () => { - const rule = createMockRulePrompt({ - series: 'cursor', - ruleName: 'ts', - globs: ['**/*.ts'], - content: '# Body' - }) - const raw = plugin.buildRuleMdcContentForTest(rule) - const lines = raw.split('\n') - const globsLine = lines.find(l => l.trimStart().startsWith('globs:')) - expect(globsLine).toBeDefined() - expect(globsLine).toBe('globs: **/*.ts') - }) - - it('should output empty string for empty globs', () => { - const rule = createMockRulePrompt({ - series: 'cursor', - ruleName: 'empty', - globs: [], - content: '# Body' - }) - const raw = plugin.buildRuleMdcContentForTest(rule) - const parsed = parseMarkdown(raw) - const fm = parsed.yamlFrontMatter as Record - expect(fm.globs).toBe('') - }) - - it('should not contain YAML array syntax for globs in raw output', () => { - const rule = createMockRulePrompt({ - series: 'cursor', - ruleName: 'multi', - globs: ['src/**', 'lib/**'], - content: '# Body' - }) - const raw = plugin.buildRuleMdcContentForTest(rule) - expect(raw).not.toMatch(/\n\s*-\s+/) - expect(raw).not.toContain(' - ') - }) - - it('should preserve rule body after front matter', () => { - const body = '# My Rule\n\nOnly for **/*.kt.' - const rule = createMockRulePrompt({ - series: 'cursor', - ruleName: 'kt', - globs: ['**/*.kt'], - content: body - }) - const raw = plugin.buildRuleMdcContentForTest(rule) - const parsed = parseMarkdown(raw) - expect(parsed.contentWithoutFrontMatter.trim()).toBe(body) - }) - - it('should not wrap glob patterns with double quotes in front matter', () => { - const rule = createMockRulePrompt({ - series: 'cursor', - ruleName: 'sql', - globs: ['**/*.sql'], - content: '# SQL rule' - }) - const raw = plugin.buildRuleMdcContentForTest(rule) - const lines = raw.split('\n') - const globsLine = lines.find(l => l.trimStart().startsWith('globs:')) - expect(globsLine).toBeDefined() - expect(globsLine).toBe('globs: **/*.sql') - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register mcp.json and skill files when any skill has mcpConfig', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - { - ...createMockSkillPrompt('skill-a', '# Skill', tempDir), - mcpConfig: { - mcpServers: {foo: {command: 'npx', args: ['-y', 'mcp-foo']}} - } - } - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.some(r => r.path === 'mcp.json')).toBe(true) - expect(results.some(r => r.path === path.join('skills-cursor', 'skill-a', 'SKILL.md'))).toBe(true) - expect(results.some(r => r.path === path.join('skills-cursor', 'skill-a', 'mcp.json'))).toBe(true) - const mcpEntry = results.find(r => r.path === 'mcp.json') - expect(mcpEntry?.getAbsolutePath()).toBe(path.join(tempDir, '.cursor', 'mcp.json')) - }) - - it('should not register mcp.json when no skill has mcpConfig but register skill files', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('skill-a', '# Skill', tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.some(r => r.path === 'mcp.json')).toBe(false) - expect(results.some(r => r.path === path.join('skills-cursor', 'skill-a', 'SKILL.md'))).toBe(true) - }) - - it('should not register mcp.json when skills is empty', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results).toHaveLength(0) - }) - - it('should register command files under commands/ when fastCommands exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [ - createMockFastCommandPrompt('compile', 'build', tempDir), - createMockFastCommandPrompt('test', void 0, tempDir) - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.length).toBeGreaterThanOrEqual(2) - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('commands', 'build-compile.md')) - expect(paths).toContain(path.join('commands', 'test.md')) - const compileEntry = results.find(r => r.path.includes('build-compile')) - expect(compileEntry?.getAbsolutePath()).toBe(path.join(tempDir, '.cursor', 'commands', 'build-compile.md')) - }) - - it('should register both mcp.json and command files when skills have mcpConfig and fastCommands exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - { - ...createMockSkillPrompt('skill-a', '# Skill', tempDir), - mcpConfig: { - mcpServers: {foo: {command: 'npx', args: ['-y', 'mcp-foo']}}, - rawContent: '{}' - } - } - ], - fastCommands: [createMockFastCommandPrompt('lint', void 0, tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.some(r => r.path === 'mcp.json')).toBe(true) - expect(results.some(r => r.path === path.join('commands', 'lint.md'))).toBe(true) - }) - - it('should not register preserved skill files (create-rule, create-skill, etc.)', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('create-rule', '# Skill', tempDir), - createMockSkillPrompt('my-custom-skill', '# Skill', tempDir) - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.some(r => r.path.includes('create-rule'))).toBe(false) - expect(results.some(r => r.path === path.join('skills-cursor', 'my-custom-skill', 'SKILL.md'))).toBe(true) - }) - }) - - describe('registerGlobalOutputDirs', () => { - it('should return empty when no fastCommands and no skills', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(0) - }) - - it('should register commands dir when fastCommands exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [createMockFastCommandPrompt('compile', void 0, tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(1) - expect(results[0].path).toBe('commands') - expect(results[0].getAbsolutePath()).toBe(path.join(tempDir, '.cursor', 'commands')) - }) - - it('should register skills-cursor/ when skills exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('custom-skill', '# Skill', tempDir) - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - const skillDirs = results.filter(r => r.path.startsWith('skills-cursor')) - expect(skillDirs).toHaveLength(1) - expect(skillDirs[0].path).toBe(path.join('skills-cursor', 'custom-skill')) - expect(skillDirs[0].getAbsolutePath()).toBe(path.join(tempDir, '.cursor', 'skills-cursor', 'custom-skill')) - }) - - it('should not register preserved skill dirs (create-rule, create-skill, etc.)', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('create-rule', '# Skill', tempDir), - createMockSkillPrompt('custom-skill', '# Skill', tempDir) - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - const skillDirs = results.filter(r => r.path.startsWith('skills-cursor')) - expect(skillDirs).toHaveLength(1) - expect(skillDirs[0].path).toBe(path.join('skills-cursor', 'custom-skill')) - expect(results.some(r => r.path.includes('create-rule'))).toBe(false) - }) - }) - - describe('canWrite', () => { - it('should return true when skills exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [{yamlFrontMatter: {name: 's'}, dir: createMockRelativePath('s', tempDir)}] - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return false when no skills and no fastCommands', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [] - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(false) - }) - - it('should return true when only fastCommands exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [createMockFastCommandPrompt('lint', void 0, tempDir)] - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - }) - - describe('writeGlobalOutputs', () => { - it('should write merged mcp.json with stdio server from skills', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - { - ...createMockSkillPrompt('skill-a', '# Skill', tempDir), - mcpConfig: { - mcpServers: { - myServer: {command: 'npx', args: ['-y', 'mcp-server'], env: {API_KEY: 'secret'}} - }, - rawContent: '{"mcpServers":{"myServer":{"command":"npx","args":["-y","mcp-server"],"env":{"API_KEY":"secret"}}}}' - } - } - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files.length).toBeGreaterThanOrEqual(2) - expect(results.files.some(f => f.path.path === 'mcp.json')).toBe(true) - expect(results.files.every(f => f.success)).toBe(true) - - const mcpPath = path.join(tempDir, '.cursor', 'mcp.json') - expect(fs.existsSync(mcpPath)).toBe(true) - const content = JSON.parse(fs.readFileSync(mcpPath, 'utf8')) as Record - expect(content.mcpServers).toBeDefined() - const servers = content.mcpServers as Record - expect(servers.myServer).toEqual({ - command: 'npx', - args: ['-y', 'mcp-server'], - env: {API_KEY: 'secret'} - }) - }) - - it('should merge with existing mcp.json and preserve user entries', async () => { - const cursorDir = path.join(tempDir, '.cursor') - fs.mkdirSync(cursorDir, {recursive: true}) - const mcpPath = path.join(cursorDir, 'mcp.json') - const existing = { - mcpServers: { - userServer: {command: 'python', args: ['server.py']}, - fromSkill: {url: 'https://old.example.com/mcp'} - } - } - fs.writeFileSync(mcpPath, JSON.stringify(existing, null, 2)) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - { - ...createMockSkillPrompt('skill-a', '# Skill', tempDir), - mcpConfig: { - mcpServers: { - fromSkill: {command: 'npx', args: ['-y', 'new-skill-mcp']} - }, - rawContent: '{}' - } - } - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - await plugin.writeGlobalOutputs(ctx) - - const content = JSON.parse(fs.readFileSync(mcpPath, 'utf8')) as Record - const servers = content.mcpServers as Record - expect(servers.userServer).toEqual({command: 'python', args: ['server.py']}) - expect(servers.fromSkill).toEqual({command: 'npx', args: ['-y', 'new-skill-mcp']}) - }) - - it('should transform remote server url or serverUrl to url', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - { - ...createMockSkillPrompt('skill-remote', '# Skill', tempDir), - mcpConfig: { - mcpServers: { - remote: {serverUrl: 'https://api.example.com/mcp', headers: {Authorization: 'Bearer x'}} - }, - rawContent: '{}' - } - } - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - await plugin.writeGlobalOutputs(ctx) - - const mcpPath = path.join(tempDir, '.cursor', 'mcp.json') - const content = JSON.parse(fs.readFileSync(mcpPath, 'utf8')) as Record - const servers = content.mcpServers as Record - expect(servers.remote).toEqual({ - url: 'https://api.example.com/mcp', - headers: {Authorization: 'Bearer x'} - }) - }) - - it('should write fast command files to ~/.cursor/commands/', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [ - createMockFastCommandPrompt('compile', 'build', tempDir), - createMockFastCommandPrompt('test', void 0, tempDir) - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files).toHaveLength(2) - - const commandsDir = path.join(tempDir, '.cursor', 'commands') - expect(fs.existsSync(commandsDir)).toBe(true) - - const buildCompilePath = path.join(commandsDir, 'build-compile.md') - const testPath = path.join(commandsDir, 'test.md') - expect(fs.existsSync(buildCompilePath)).toBe(true) - expect(fs.existsSync(testPath)).toBe(true) - - const buildCompileContent = fs.readFileSync(buildCompilePath, 'utf8') - expect(buildCompileContent).toContain('description: Fast command') - expect(buildCompileContent).toContain('Run something') - - const testContent = fs.readFileSync(testPath, 'utf8') - expect(testContent).toContain('Run something') - }) - - it('should write skill to ~/.cursor/skills-cursor//SKILL.md', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('my-skill', '# My Skill Content', tempDir)] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - await plugin.writeGlobalOutputs(ctx) - - const skillPath = path.join(tempDir, '.cursor', 'skills-cursor', 'my-skill', 'SKILL.md') - expect(fs.existsSync(skillPath)).toBe(true) - const content = fs.readFileSync(skillPath, 'utf8') - expect(content).toContain('name: my-skill') - expect(content).toContain('# My Skill Content') - }) - - it('should not overwrite preserved skill (create-rule)', async () => { - const preservedSkillDir = path.join(tempDir, '.cursor', 'skills-cursor', 'create-rule') - fs.mkdirSync(preservedSkillDir, {recursive: true}) - const originalContent = '# Original Cursor built-in skill' - fs.writeFileSync(path.join(preservedSkillDir, 'SKILL.md'), originalContent) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('create-rule', '# Would overwrite', tempDir), - createMockSkillPrompt('custom-skill', '# Custom', tempDir) - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - await plugin.writeGlobalOutputs(ctx) - - const preservedPath = path.join(preservedSkillDir, 'SKILL.md') - expect(fs.readFileSync(preservedPath, 'utf8')).toBe(originalContent) - const customPath = path.join(tempDir, '.cursor', 'skills-cursor', 'custom-skill', 'SKILL.md') - expect(fs.existsSync(customPath)).toBe(true) - expect(fs.readFileSync(customPath, 'utf8')).toContain('# Custom') - }) - }) - - describe('clean effect', () => { - it('should reset mcp.json to empty mcpServers shell on clean', async () => { - const cursorDir = path.join(tempDir, '.cursor') - fs.mkdirSync(cursorDir, {recursive: true}) - const mcpPath = path.join(cursorDir, 'mcp.json') - fs.writeFileSync(mcpPath, JSON.stringify({mcpServers: {some: {command: 'npx'}}}, null, 2)) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)} - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as any - - await plugin.onCleanComplete(ctx) - - expect(fs.existsSync(mcpPath)).toBe(true) - const content = JSON.parse(fs.readFileSync(mcpPath, 'utf8')) as Record - expect(content).toEqual({mcpServers: {}}) - }) - - it('should not write on clean when dryRun is true', async () => { - const cursorDir = path.join(tempDir, '.cursor') - fs.mkdirSync(cursorDir, {recursive: true}) - const mcpPath = path.join(cursorDir, 'mcp.json') - const original = {mcpServers: {keep: {command: 'npx'}}} - fs.writeFileSync(mcpPath, JSON.stringify(original, null, 2)) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)} - }, - logger: createLogger('test', 'debug'), - dryRun: true - } as any - - await plugin.onCleanComplete(ctx) - - const content = JSON.parse(fs.readFileSync(mcpPath, 'utf8')) as Record - expect(content).toEqual(original) - }) - }) - - describe('project outputs', () => { - it('should implement writeProjectOutputs', () => expect(plugin.writeProjectOutputs).toBeDefined()) - - it('should implement registerProjectOutputFiles and registerProjectOutputDirs', () => { - expect(plugin.registerProjectOutputFiles).toBeDefined() - expect(plugin.registerProjectOutputDirs).toBeDefined() - }) - - it('should implement registerGlobalOutputDirs for commands dir', () => expect(plugin.registerGlobalOutputDirs).toBeDefined()) - - it('should register .cursor/rules dir for each project when globalMemory exists', async () => { - const projectDir = createMockRelativePath('project-a', tempDir) - const ctx = { - collectedInputContext: { - workspace: { - projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) - } - } as unknown as OutputPluginContext - - const dirs = await plugin.registerProjectOutputDirs(ctx) - expect(dirs.length).toBe(1) - expect(dirs[0].path).toBe(path.join('project-a', '.cursor', 'rules')) - expect(dirs[0].getAbsolutePath()).toBe(path.join(tempDir, 'project-a', '.cursor', 'rules')) - }) - - it('should register .cursor/rules/global.mdc for each project when globalMemory exists', async () => { - const projectDir = createMockRelativePath('project-a', tempDir) - const ctx = { - collectedInputContext: { - workspace: { - projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) - } - } as unknown as OutputPluginContext - - const files = await plugin.registerProjectOutputFiles(ctx) - 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 () => { - const projectDir = createMockRelativePath('project-a', tempDir) - const ctx = { - collectedInputContext: { - workspace: { - projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: void 0 - } - } as unknown as OutputPluginContext - - const dirs = await plugin.registerProjectOutputDirs(ctx) - const files = await plugin.registerProjectOutputFiles(ctx) - expect(dirs.length).toBe(0) - expect(files.length).toBe(0) - }) - - it('should return true from canWrite when only globalMemory and projects exist', async () => { - const projectDir = createMockRelativePath('project-a', tempDir) - const ctx = { - collectedInputContext: { - workspace: { - projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir), - skills: [], - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should write global.mdc with alwaysApply true and global content', async () => { - const projectDir = createMockRelativePath('project-a', tempDir) - const globalContent = '# Global prompt\n\nAlways apply this.' - const ctx = { - collectedInputContext: { - workspace: { - projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: createMockGlobalMemoryPrompt(globalContent, tempDir) - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeProjectOutputs(ctx) - expect(results.files.length).toBe(1) - expect(results.files[0].success).toBe(true) - - const fullPath = path.join(tempDir, 'project-a', '.cursor', 'rules', 'global.mdc') - expect(fs.existsSync(fullPath)).toBe(true) - const content = fs.readFileSync(fullPath, 'utf8') - expect(content).toContain('alwaysApply: true') - expect(content).toContain('Global prompt (synced)') - expect(content).toContain(globalContent) - }) - - it('should not write files on dryRun', async () => { - const projectDir = createMockRelativePath('project-a', tempDir) - const ctx = { - collectedInputContext: { - workspace: { - projects: [{name: 'project-a', dirFromWorkspacePath: projectDir}], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) - }, - logger: createLogger('test', 'debug'), - dryRun: true - } as unknown as OutputWriteContext - - const results = await plugin.writeProjectOutputs(ctx) - expect(results.files.length).toBe(1) - expect(results.files[0].success).toBe(true) - - const fullPath = path.join(tempDir, 'project-a', '.cursor', 'rules', 'global.mdc') - expect(fs.existsSync(fullPath)).toBe(false) - }) - }) -}) diff --git a/packages/plugin-cursor/src/CursorOutputPlugin.ts b/packages/plugin-cursor/src/CursorOutputPlugin.ts deleted file mode 100644 index e9f5805e..00000000 --- a/packages/plugin-cursor/src/CursorOutputPlugin.ts +++ /dev/null @@ -1,534 +0,0 @@ -import type { - FastCommandPrompt, - OutputPluginContext, - OutputWriteContext, - Project, - RulePrompt, - SkillPrompt, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import {Buffer} from 'node:buffer' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' -import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' - -const GLOBAL_CONFIG_DIR = '.cursor' -const MCP_CONFIG_FILE = 'mcp.json' -const COMMANDS_SUBDIR = 'commands' -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', - 'create-skill', - 'create-subagent', - 'migrate-to-skills', - 'update-cursor-settings' -]) - -export class CursorOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('CursorOutputPlugin', { - globalConfigDir: GLOBAL_CONFIG_DIR, - outputFileName: '', - dependsOn: [PLUGIN_NAMES.AgentsOutput], - indexignore: '.cursorignore' - }) - - this.registerCleanEffect('mcp-config-cleanup', async ctx => { - const globalDir = this.getGlobalConfigDir() - const mcpConfigPath = path.join(globalDir, MCP_CONFIG_FILE) - const emptyMcpConfig = {mcpServers: {}} - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'mcpConfigCleanup', path: mcpConfigPath}) - return {success: true, description: 'Would reset mcp.json to empty shell'} - } - - try { - this.ensureDirectory(globalDir) - fs.writeFileSync(mcpConfigPath, JSON.stringify(emptyMcpConfig, null, 2)) - this.log.trace({action: 'clean', type: 'mcpConfigCleanup', path: mcpConfigPath}) - return {success: true, description: 'Reset mcp.json to empty shell'} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'clean', type: 'mcpConfigCleanup', path: mcpConfigPath, error: errMsg}) - return {success: false, error: error as Error, description: 'Failed to reset mcp.json'} - } - }) - } - - async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const globalDir = this.getGlobalConfigDir() - const {fastCommands, skills, rules} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - if (filteredCommands.length > 0) { - const commandsDir = this.getGlobalCommandsDir() - results.push({pathKind: FilePathKind.Relative, path: COMMANDS_SUBDIR, basePath: globalDir, getDirectoryName: () => COMMANDS_SUBDIR, getAbsolutePath: () => commandsDir}) - } - } - - if (skills != null && skills.length > 0) { - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter.name - if (this.isPreservedSkill(skillName)) continue - const skillPath = path.join(globalDir, SKILLS_CURSOR_SUBDIR, skillName) - results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => skillPath}) - } - } - - const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === '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 - } - - async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const globalDir = this.getGlobalConfigDir() - const {skills, fastCommands} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] - const hasAnyMcpConfig = filteredSkills.some(s => s.mcpConfig != null) - - if (hasAnyMcpConfig) { - const mcpConfigPath = path.join(globalDir, MCP_CONFIG_FILE) - results.push({pathKind: FilePathKind.Relative, path: MCP_CONFIG_FILE, basePath: globalDir, getDirectoryName: () => GLOBAL_CONFIG_DIR, getAbsolutePath: () => mcpConfigPath}) - } - - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - const commandsDir = this.getGlobalCommandsDir() - const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) - for (const cmd of filteredCommands) { - const fileName = this.transformFastCommandName(cmd, transformOptions) - const fullPath = path.join(commandsDir, fileName) - results.push({pathKind: FilePathKind.Relative, path: path.join(COMMANDS_SUBDIR, fileName), basePath: globalDir, getDirectoryName: () => COMMANDS_SUBDIR, getAbsolutePath: () => fullPath}) - } - } - - const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === '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 (filteredSkills.length === 0) return results - - const skillsCursorDir = this.getSkillsCursorDir() - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter.name - if (this.isPreservedSkill(skillName)) continue - const skillDir = path.join(skillsCursorDir, skillName) - results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, SKILL_FILE_NAME), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, SKILL_FILE_NAME)}) - - if (skill.mcpConfig != null) results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, MCP_CONFIG_FILE), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, MCP_CONFIG_FILE)}) - - if (skill.childDocs != null) { - for (const childDoc of skill.childDocs) { - const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') - results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, outputRelativePath), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, outputRelativePath)}) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, resource.relativePath), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, resource.relativePath)}) - } - } - return results - } - - async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {workspace, globalMemory, rules} = ctx.collectedInputContext - const hasProjectRules = rules?.some(r => this.normalizeRuleScope(r) === 'project') ?? false - if (globalMemory == null && !hasProjectRules) return results - for (const project of workspace.projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - results.push(this.createProjectRulesDirRelativePath(projectDir)) - } - return results - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {workspace, globalMemory, rules} = ctx.collectedInputContext - if (globalMemory == null && rules == null) 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 (rules != null && rules.length > 0) { - for (const project of workspace.projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - const projectRules = applySubSeriesGlobPrefix(filterRulesByProjectConfig(rules.filter(r => this.normalizeRuleScope(r) === 'project'), project.projectConfig), project.projectConfig) - for (const rule of projectRules) results.push(this.createProjectRuleFileRelativePath(projectDir, this.buildRuleFileName(rule))) - } - } - - results.push(...this.registerProjectIgnoreOutputFiles(workspace.projects)) - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - 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 || hasRules || hasCursorIgnore) return true - this.log.trace({action: 'skip', reason: 'noOutputs'}) - return false - } - - async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {skills, fastCommands, rules} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - if (skills != null && skills.length > 0) { - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - const mcpResult = await this.writeGlobalMcpConfig(ctx, filteredSkills) - if (mcpResult != null) fileResults.push(mcpResult) - const skillsCursorDir = this.getSkillsCursorDir() - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter.name - if (this.isPreservedSkill(skillName)) continue - fileResults.push(...await this.writeGlobalSkill(ctx, skillsCursorDir, skill)) - } - } - - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - const commandsDir = this.getGlobalCommandsDir() - for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalFastCommand(ctx, commandsDir, cmd)) - } - - const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global') - if (globalRules == null || globalRules.length === 0) return {files: fileResults, dirs: dirResults} - - const globalRulesDir = path.join(this.getGlobalConfigDir(), RULES_SUBDIR) - for (const rule of globalRules) fileResults.push(await this.writeRuleMdcFile(ctx, globalRulesDir, rule, this.getGlobalConfigDir())) - return {files: fileResults, dirs: dirResults} - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - const {workspace, globalMemory, rules} = ctx.collectedInputContext - if (globalMemory != null) { - const content = this.buildGlobalRuleContent(globalMemory.content as string) - for (const project of workspace.projects) { - if (project.dirFromWorkspacePath == null) continue - fileResults.push(await this.writeProjectGlobalRule(ctx, project, content)) - } - } - - if (rules != null && rules.length > 0) { - for (const project of workspace.projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - const projectRules = applySubSeriesGlobPrefix(filterRulesByProjectConfig(rules.filter(r => this.normalizeRuleScope(r) === 'project'), project.projectConfig), project.projectConfig) - if (projectRules.length === 0) continue - const rulesDir = path.join(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR) - for (const rule of projectRules) fileResults.push(await this.writeRuleMdcFile(ctx, rulesDir, rule, projectDir.basePath)) - } - } - - fileResults.push(...await this.writeProjectIgnoreFiles(ctx)) - return {files: fileResults, dirs: dirResults} - } - - private createProjectRulesDirRelativePath(projectDir: RelativePath): RelativePath { - const rulesDirPath = path.join(projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR) - return {pathKind: FilePathKind.Relative, path: rulesDirPath, basePath: projectDir.basePath, getDirectoryName: () => RULES_SUBDIR, getAbsolutePath: () => path.join(projectDir.basePath, rulesDirPath)} - } - - private createProjectRuleFileRelativePath(projectDir: RelativePath, fileName: string): RelativePath { - const filePath = path.join(projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR, fileName) - return {pathKind: FilePathKind.Relative, path: filePath, basePath: projectDir.basePath, getDirectoryName: () => RULES_SUBDIR, getAbsolutePath: () => path.join(projectDir.basePath, filePath)} - } - - private buildGlobalRuleContent(content: string): string { - return buildMarkdownWithFrontMatter({description: 'Global prompt (synced)', alwaysApply: true}, content) - } - - private async writeProjectGlobalRule(ctx: OutputWriteContext, project: Project, content: string): Promise { - const projectDir = project.dirFromWorkspacePath! - const rulesDir = path.join(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR) - const fullPath = path.join(rulesDir, GLOBAL_RULE_FILE) - const relativePath = this.createProjectRuleFileRelativePath(projectDir, GLOBAL_RULE_FILE) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'globalRule', path: fullPath}) - return {path: relativePath, success: true, skipped: false} - } - - try { - this.ensureDirectory(rulesDir) - this.writeFileSync(fullPath, content) - this.log.trace({action: 'write', type: 'globalRule', path: fullPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalRule', path: fullPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private isPreservedSkill(name: string): boolean { return PRESERVED_SKILLS.has(name) } - private getSkillsCursorDir(): string { return path.join(this.getGlobalConfigDir(), SKILLS_CURSOR_SUBDIR) } - private getGlobalCommandsDir(): string { return path.join(this.getGlobalConfigDir(), COMMANDS_SUBDIR) } - - private async writeGlobalFastCommand(ctx: OutputWriteContext, commandsDir: string, cmd: FastCommandPrompt): Promise { - const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) - const fileName = this.transformFastCommandName(cmd, transformOptions) - const fullPath = path.join(commandsDir, fileName) - const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(COMMANDS_SUBDIR, fileName), basePath: this.getGlobalConfigDir(), getDirectoryName: () => COMMANDS_SUBDIR, getAbsolutePath: () => fullPath} - const content = this.buildMarkdownContentWithRaw(cmd.content, cmd.yamlFrontMatter, cmd.rawFrontMatter) - - if (ctx.dryRun === true) { this.log.trace({action: 'dryRun', type: 'globalFastCommand', path: fullPath}); return {path: relativePath, success: true, skipped: false} } - - try { - this.ensureDirectory(commandsDir) - fs.writeFileSync(fullPath, content) - this.log.trace({action: 'write', type: 'globalFastCommand', path: fullPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalFastCommand', path: fullPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private async writeGlobalMcpConfig(ctx: OutputWriteContext, skills: readonly SkillPrompt[]): Promise { - const mergedMcpServers: Record = {} - for (const skill of skills) { - if (skill.mcpConfig == null) continue - for (const [mcpName, mcpConfig] of Object.entries(skill.mcpConfig.mcpServers)) mergedMcpServers[mcpName] = this.transformMcpConfigForCursor({...(mcpConfig as unknown as Record)}) - } - if (Object.keys(mergedMcpServers).length === 0) return null - - const globalDir = this.getGlobalConfigDir() - const mcpConfigPath = path.join(globalDir, MCP_CONFIG_FILE) - const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: MCP_CONFIG_FILE, basePath: globalDir, getDirectoryName: () => GLOBAL_CONFIG_DIR, getAbsolutePath: () => mcpConfigPath} - - let existingConfig: Record = {} - try { if (this.existsSync(mcpConfigPath)) existingConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')) as Record } - catch { existingConfig = {} } - - const existingMcpServers = (existingConfig['mcpServers'] as Record) ?? {} - existingConfig['mcpServers'] = {...existingMcpServers, ...mergedMcpServers} - const content = JSON.stringify(existingConfig, null, 2) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'globalMcpConfig', path: mcpConfigPath, serverCount: Object.keys(mergedMcpServers).length}) - return {path: relativePath, success: true, skipped: false} - } - - try { - this.ensureDirectory(globalDir) - fs.writeFileSync(mcpConfigPath, content) - this.log.trace({action: 'write', type: 'globalMcpConfig', path: mcpConfigPath, serverCount: Object.keys(mergedMcpServers).length}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalMcpConfig', path: mcpConfigPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private transformMcpConfigForCursor(config: Record): Record { - const result: Record = {} - if (config['command'] != null) { - result['command'] = config['command'] - if (config['args'] != null) result['args'] = config['args'] - if (config['env'] != null) result['env'] = config['env'] - return result - } - const url = config['url'] ?? config['serverUrl'] - if (url == null) return result - result['url'] = url - if (config['headers'] != null) result['headers'] = config['headers'] - return result - } - - private async writeGlobalSkill(ctx: OutputWriteContext, skillsDir: string, skill: SkillPrompt): Promise { - const results: WriteResult[] = [] - const skillName = skill.yamlFrontMatter.name - const skillDir = path.join(skillsDir, skillName) - const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) - const globalDir = this.getGlobalConfigDir() - const skillRelativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, SKILL_FILE_NAME), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => skillFilePath} - - const frontMatterData = this.buildSkillFrontMatter(skill) - const skillContent = buildMarkdownWithFrontMatter(frontMatterData, skill.content as string) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'skill', path: skillFilePath}) - results.push({path: skillRelativePath, success: true, skipped: false}) - } else { - try { - this.ensureDirectory(skillDir) - this.writeFileSync(skillFilePath, skillContent) - this.log.trace({action: 'write', type: 'skill', path: skillFilePath}) - results.push({path: skillRelativePath, success: true}) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'skill', path: skillFilePath, error: errMsg}) - results.push({path: skillRelativePath, success: false, error: error as Error}) - } - } - - if (skill.mcpConfig != null) results.push(await this.writeSkillMcpConfig(ctx, skill, skillDir, globalDir)) - if (skill.childDocs != null) { for (const childDoc of skill.childDocs) results.push(await this.writeSkillChildDoc(ctx, childDoc, skillDir, skillName, globalDir)) } - if (skill.resources != null) { for (const resource of skill.resources) results.push(await this.writeSkillResource(ctx, resource, skillDir, skillName, globalDir)) } - return results - } - - private buildSkillFrontMatter(skill: SkillPrompt): Record { - const fm = skill.yamlFrontMatter - return {name: fm.name, description: fm.description, ...fm.displayName != null && {displayName: fm.displayName}, ...fm.keywords != null && fm.keywords.length > 0 && {keywords: fm.keywords}, ...fm.author != null && {author: fm.author}, ...fm.version != null && {version: fm.version}, ...fm.allowTools != null && fm.allowTools.length > 0 && {allowTools: fm.allowTools}} - } - - private async writeSkillMcpConfig(ctx: OutputWriteContext, skill: SkillPrompt, skillDir: string, globalDir: string): Promise { - const skillName = skill.yamlFrontMatter.name - const mcpConfigPath = path.join(skillDir, MCP_CONFIG_FILE) - const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, MCP_CONFIG_FILE), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => mcpConfigPath} - const mcpConfigContent = skill.mcpConfig!.rawContent - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'mcpConfig', path: mcpConfigPath}) - return {path: relativePath, success: true, skipped: false} - } - try { - this.ensureDirectory(skillDir) - this.writeFileSync(mcpConfigPath, mcpConfigContent) - this.log.trace({action: 'write', type: 'mcpConfig', path: mcpConfigPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'mcpConfig', path: mcpConfigPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private async writeSkillChildDoc(ctx: OutputWriteContext, childDoc: {relativePath: string, content: unknown}, skillDir: string, skillName: string, globalDir: string): Promise { - const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') - const childDocPath = path.join(skillDir, outputRelativePath) - const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, outputRelativePath), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => childDocPath} - const content = childDoc.content as string - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'childDoc', path: childDocPath}) - return {path: relativePath, success: true, skipped: false} - } - try { - const parentDir = path.dirname(childDocPath) - this.ensureDirectory(parentDir) - this.writeFileSync(childDocPath, content) - this.log.trace({action: 'write', type: 'childDoc', path: childDocPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'childDoc', path: childDocPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private async writeSkillResource(ctx: OutputWriteContext, resource: {relativePath: string, content: string, encoding: 'text' | 'base64'}, skillDir: string, skillName: string, globalDir: string): Promise { - const resourcePath = path.join(skillDir, resource.relativePath) - const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_CURSOR_SUBDIR, skillName, resource.relativePath), basePath: globalDir, getDirectoryName: () => skillName, getAbsolutePath: () => resourcePath} - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'resource', path: resourcePath}) - return {path: relativePath, success: true, skipped: false} - } - try { - const parentDir = path.dirname(resourcePath) - this.ensureDirectory(parentDir) - if (resource.encoding === 'base64') { - const buffer = Buffer.from(resource.content, 'base64') - this.writeFileSyncBuffer(resourcePath, buffer) - } else this.writeFileSync(resourcePath, resource.content) - this.log.trace({action: 'write', type: 'resource', path: resourcePath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'resource', path: resourcePath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private buildRuleFileName(rule: RulePrompt): string { return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.mdc` } - - protected buildRuleMdcContent(rule: RulePrompt): string { - const fmData: Record = {alwaysApply: false, globs: rule.globs.length > 0 ? rule.globs.join(', ') : ''} - const raw = buildMarkdownWithFrontMatter(fmData, rule.content) - const lines = raw.split('\n') - const transformedLines = lines.map(line => { - const match = /^(\s*globs:\s*)(['"])(.*)\2\s*$/.exec(line) - if (match == null) return line - const prefix = match[1] ?? 'globs: ' - const value = match[3] ?? '' - if (value.trim().length === 0) return line - return `${prefix}${value}` - }) - return transformedLines.join('\n') - } - - 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/packages/plugin-cursor/src/index.ts b/packages/plugin-cursor/src/index.ts deleted file mode 100644 index 4c94c1bb..00000000 --- a/packages/plugin-cursor/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - CursorOutputPlugin -} from './CursorOutputPlugin' diff --git a/packages/plugin-cursor/tsconfig.eslint.json b/packages/plugin-cursor/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-cursor/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-cursor/tsconfig.json b/packages/plugin-cursor/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-cursor/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-cursor/tsconfig.lib.json b/packages/plugin-cursor/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-cursor/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-cursor/tsconfig.test.json b/packages/plugin-cursor/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-cursor/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-cursor/tsdown.config.ts b/packages/plugin-cursor/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-cursor/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-cursor/vite.config.ts b/packages/plugin-cursor/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-cursor/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-cursor/vitest.config.ts b/packages/plugin-cursor/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-cursor/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-droid-cli/eslint.config.ts b/packages/plugin-droid-cli/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-droid-cli/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-droid-cli/package.json b/packages/plugin-droid-cli/package.json deleted file mode 100644 index 54d145d5..00000000 --- a/packages/plugin-droid-cli/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-droid-cli", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Droid CLI output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-droid-cli/src/DroidCLIOutputPlugin.test.ts b/packages/plugin-droid-cli/src/DroidCLIOutputPlugin.test.ts deleted file mode 100644 index 0cad3c85..00000000 --- a/packages/plugin-droid-cli/src/DroidCLIOutputPlugin.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import type {CollectedInputContext, FastCommandPrompt, OutputPluginContext, Project, RelativePath, RootPath, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {DroidCLIOutputPlugin} from './DroidCLIOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { // Helper to create mock RelativePath - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -class TestableDroidCLIOutputPlugin extends DroidCLIOutputPlugin { // Testable subclass to mock home dir - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -describe('droidCLIOutputPlugin', () => { - let tempDir: string, - plugin: TestableDroidCLIOutputPlugin, - mockContext: OutputPluginContext - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'droid-test-')) - plugin = new TestableDroidCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - mockContext = { - collectedInputContext: { - workspace: { - projects: [], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: { - type: PromptKind.GlobalMemory, - content: 'Global Memory Content', - filePathKind: FilePathKind.Absolute, - dir: createMockRelativePath('.', tempDir), - markdownContents: [] - }, - fastCommands: [], - subAgents: [], - skills: [] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - }, 30000) - - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch { - } // ignore cleanup errors - } - }) - - describe('registerGlobalOutputDirs', () => { - it('should register commands, agents, and skills subdirectories in .factory', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - - const dirPaths = dirs.map(d => d.path) - expect(dirPaths).toContain('commands') - expect(dirPaths).toContain('agents') - expect(dirPaths).toContain('skills') - - const expectedBasePath = path.join(tempDir, '.factory') - dirs.forEach(d => expect(d.basePath).toBe(expectedBasePath)) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should register project cleanup directories', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - rootMemoryPrompt: { - type: PromptKind.ProjectRootMemory, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir) as unknown as RootPath, - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} - }, - childMemoryPrompts: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const dirs = await plugin.registerProjectOutputDirs(ctxWithProject) - const dirPaths = dirs.map(d => d.path) // Expect 3 dirs: .factory/commands, .factory/agents, .factory/skills - - expect(dirPaths.some(p => p.includes(path.join('.factory', 'commands')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.factory', 'agents')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.factory', 'skills')))).toBe(true) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register AGENTS.md in global config dir', async () => { - const files = await plugin.registerGlobalOutputFiles(mockContext) - const outputFile = files.find(f => f.path === 'AGENTS.md') - - expect(outputFile).toBeDefined() - expect(outputFile?.basePath).toBe(path.join(tempDir, '.factory')) - }) - - it('should register fast commands in commands subdirectory', async () => { - const mockCmd: FastCommandPrompt = { - type: PromptKind.FastCommand, - commandName: 'test-cmd', - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-cmd', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, description: 'desc'} - } - - const ctxWithCmd = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - fastCommands: [mockCmd] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithCmd) - const cmdFile = files.find(f => f.path.includes('test-cmd.md')) - - expect(cmdFile).toBeDefined() - expect(cmdFile?.path).toContain('commands') - expect(cmdFile?.basePath).toBe(path.join(tempDir, '.factory')) - }) - - it('should register sub agents in agents subdirectory', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-agent.md', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'agent', description: 'desc'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('test-agent.md')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('agents') - expect(agentFile?.basePath).toBe(path.join(tempDir, '.factory')) - }) - - it('should strip .mdx suffix from sub agent path and use .md', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'agent content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('code-review.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'desc'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('agents')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('code-review.cn.md') - expect(agentFile?.path).not.toContain('.mdx') - }) - - it('should register skills in skills subdirectory', 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'} - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) - const skillFile = files.find(f => f.path.includes('SKILL.md')) - - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain('skills') - expect(skillFile?.basePath).toBe(path.join(tempDir, '.factory')) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should return empty array', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - childMemoryPrompts: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const files = await plugin.registerProjectOutputFiles(ctxWithProject) - expect(files).toEqual([]) - }) - }) -}) diff --git a/packages/plugin-droid-cli/src/DroidCLIOutputPlugin.ts b/packages/plugin-droid-cli/src/DroidCLIOutputPlugin.ts deleted file mode 100644 index 3d4333db..00000000 --- a/packages/plugin-droid-cli/src/DroidCLIOutputPlugin.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { - OutputWriteContext, - SkillPrompt, - WriteResult -} from '@truenine/plugin-shared' -import * as path from 'node:path' -import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' - -const GLOBAL_MEMORY_FILE = 'AGENTS.md' -const GLOBAL_CONFIG_DIR = '.factory' - -export class DroidCLIOutputPlugin extends BaseCLIOutputPlugin { - constructor() { - super('DroidCLIOutputPlugin', { - globalConfigDir: GLOBAL_CONFIG_DIR, - outputFileName: GLOBAL_MEMORY_FILE, - supportsFastCommands: true, - supportsSubAgents: true, - supportsSkills: true - }) // Droid uses default subdir names - } - - protected override async writeSkill( // Override writeSkill to preserve simplified front matter logic - ctx: OutputWriteContext, - basePath: string, - skill: SkillPrompt - ): Promise { - const results: WriteResult[] = [] - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - const targetDir = path.join(basePath, this.skillsSubDir, skillName) - const fullPath = path.join(targetDir, 'SKILL.md') - - const simplifiedFrontMatter = skill.yamlFrontMatter != null // Droid-specific: Simplify front matter - ? {name: skill.yamlFrontMatter.name, description: skill.yamlFrontMatter.description} - : void 0 - - const content = this.buildMarkdownContent(skill.content as string, simplifiedFrontMatter) - - const mainFileResult = await this.writeFile(ctx, fullPath, content, 'skill') - results.push(mainFileResult) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - const refResults = await this.writeSkillReferenceDocument(ctx, targetDir, skillName, refDoc, basePath) - results.push(...refResults) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) { - const refResults = await this.writeSkillResource(ctx, targetDir, skillName, resource, basePath) - results.push(...refResults) - } - } - - return results - } -} diff --git a/packages/plugin-droid-cli/src/index.ts b/packages/plugin-droid-cli/src/index.ts deleted file mode 100644 index 040d09e7..00000000 --- a/packages/plugin-droid-cli/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - DroidCLIOutputPlugin -} from './DroidCLIOutputPlugin' diff --git a/packages/plugin-droid-cli/tsconfig.eslint.json b/packages/plugin-droid-cli/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-droid-cli/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-droid-cli/tsconfig.json b/packages/plugin-droid-cli/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-droid-cli/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-droid-cli/tsconfig.lib.json b/packages/plugin-droid-cli/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-droid-cli/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-droid-cli/tsconfig.test.json b/packages/plugin-droid-cli/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-droid-cli/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-droid-cli/tsdown.config.ts b/packages/plugin-droid-cli/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-droid-cli/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-droid-cli/vite.config.ts b/packages/plugin-droid-cli/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-droid-cli/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-droid-cli/vitest.config.ts b/packages/plugin-droid-cli/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-droid-cli/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-editorconfig/eslint.config.ts b/packages/plugin-editorconfig/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-editorconfig/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-editorconfig/package.json b/packages/plugin-editorconfig/package.json deleted file mode 100644 index a39cba88..00000000 --- a/packages/plugin-editorconfig/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-editorconfig", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "EditorConfig output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-editorconfig/src/EditorConfigOutputPlugin.ts b/packages/plugin-editorconfig/src/EditorConfigOutputPlugin.ts deleted file mode 100644 index 77e8b575..00000000 --- a/packages/plugin-editorconfig/src/EditorConfigOutputPlugin.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { - OutputPluginContext, - OutputWriteContext, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {FilePathKind} from '@truenine/plugin-shared' - -const EDITOR_CONFIG_FILE = '.editorconfig' - -/** - * Output plugin for writing .editorconfig files to project directories. - * Reads EditorConfig files collected by EditorConfigInputPlugin. - */ -export class EditorConfigOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('EditorConfigOutputPlugin') - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - const {editorConfigFiles} = ctx.collectedInputContext - - if (editorConfigFiles == null || editorConfigFiles.length === 0) return results - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - if (project.isPromptSourceProject === true) continue - - const filePath = this.joinPath(projectDir.path, EDITOR_CONFIG_FILE) - results.push({ - pathKind: FilePathKind.Relative, - path: filePath, - basePath: projectDir.basePath, - getDirectoryName: () => projectDir.getDirectoryName(), - getAbsolutePath: () => this.resolvePath(projectDir.basePath, filePath) - }) - } - - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {editorConfigFiles} = ctx.collectedInputContext - if (editorConfigFiles != null && editorConfigFiles.length > 0) return true - - this.log.debug('skipped', {reason: 'no EditorConfig files found'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const {projects} = ctx.collectedInputContext.workspace - const {editorConfigFiles} = ctx.collectedInputContext - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - if (editorConfigFiles == null || editorConfigFiles.length === 0) return {files: fileResults, dirs: dirResults} - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - if (project.isPromptSourceProject === true) continue - - const projectName = project.name ?? 'unknown' - - for (const config of editorConfigFiles) { - const fullPath = this.resolvePath(projectDir.basePath, projectDir.path, EDITOR_CONFIG_FILE) - const result = await this.writeFile(ctx, fullPath, config.content, `project:${projectName}/.editorconfig`) - fileResults.push(result) - } - } - - return {files: fileResults, dirs: dirResults} - } -} diff --git a/packages/plugin-editorconfig/src/index.ts b/packages/plugin-editorconfig/src/index.ts deleted file mode 100644 index 189999e5..00000000 --- a/packages/plugin-editorconfig/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - EditorConfigOutputPlugin -} from './EditorConfigOutputPlugin' diff --git a/packages/plugin-editorconfig/tsconfig.eslint.json b/packages/plugin-editorconfig/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-editorconfig/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-editorconfig/tsconfig.json b/packages/plugin-editorconfig/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-editorconfig/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-editorconfig/tsconfig.lib.json b/packages/plugin-editorconfig/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-editorconfig/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-editorconfig/tsconfig.test.json b/packages/plugin-editorconfig/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-editorconfig/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-editorconfig/tsdown.config.ts b/packages/plugin-editorconfig/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-editorconfig/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-editorconfig/vite.config.ts b/packages/plugin-editorconfig/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-editorconfig/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-editorconfig/vitest.config.ts b/packages/plugin-editorconfig/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-editorconfig/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-gemini-cli/eslint.config.ts b/packages/plugin-gemini-cli/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-gemini-cli/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-gemini-cli/package.json b/packages/plugin-gemini-cli/package.json deleted file mode 100644 index 4fa7e2d2..00000000 --- a/packages/plugin-gemini-cli/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-gemini-cli", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Gemini CLI output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-gemini-cli/src/GeminiCLIOutputPlugin.ts b/packages/plugin-gemini-cli/src/GeminiCLIOutputPlugin.ts deleted file mode 100644 index 6f6828b4..00000000 --- a/packages/plugin-gemini-cli/src/GeminiCLIOutputPlugin.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' - -const PROJECT_MEMORY_FILE = 'GEMINI.md' -const GLOBAL_CONFIG_DIR = '.gemini' - -export class GeminiCLIOutputPlugin extends BaseCLIOutputPlugin { - constructor() { - super('GeminiCLIOutputPlugin', { - globalConfigDir: GLOBAL_CONFIG_DIR, - outputFileName: PROJECT_MEMORY_FILE, - supportsFastCommands: false, - supportsSubAgents: false, - supportsSkills: false - }) - } -} diff --git a/packages/plugin-gemini-cli/src/index.ts b/packages/plugin-gemini-cli/src/index.ts deleted file mode 100644 index 4a330a0d..00000000 --- a/packages/plugin-gemini-cli/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - GeminiCLIOutputPlugin -} from './GeminiCLIOutputPlugin' diff --git a/packages/plugin-gemini-cli/tsconfig.eslint.json b/packages/plugin-gemini-cli/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-gemini-cli/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-gemini-cli/tsconfig.json b/packages/plugin-gemini-cli/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-gemini-cli/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-gemini-cli/tsconfig.lib.json b/packages/plugin-gemini-cli/tsconfig.lib.json deleted file mode 100644 index 73898d16..00000000 --- a/packages/plugin-gemini-cli/tsconfig.lib.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "noEmit": false, - "outDir": "../dist", - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-gemini-cli/tsconfig.test.json b/packages/plugin-gemini-cli/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-gemini-cli/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-gemini-cli/tsdown.config.ts b/packages/plugin-gemini-cli/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-gemini-cli/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-gemini-cli/vite.config.ts b/packages/plugin-gemini-cli/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-gemini-cli/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-gemini-cli/vitest.config.ts b/packages/plugin-gemini-cli/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-gemini-cli/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-git-exclude/eslint.config.ts b/packages/plugin-git-exclude/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-git-exclude/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-git-exclude/package.json b/packages/plugin-git-exclude/package.json deleted file mode 100644 index 5253d280..00000000 --- a/packages/plugin-git-exclude/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-git-exclude", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Git exclude output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-git-exclude/src/GitExcludeOutputPlugin.test.ts b/packages/plugin-git-exclude/src/GitExcludeOutputPlugin.test.ts deleted file mode 100644 index 4c0b1de6..00000000 --- a/packages/plugin-git-exclude/src/GitExcludeOutputPlugin.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import * as fs from 'node:fs' -import {createLogger} from '@truenine/plugin-shared' -import {beforeEach, describe, expect, it, vi} from 'vitest' -import {GitExcludeOutputPlugin} from './GitExcludeOutputPlugin' - -vi.mock('node:fs') - -const dirStat = {isDirectory: () => true, isFile: () => false} as any -const fileStat = {isDirectory: () => false, isFile: () => true} as any - -function setupFsMocks(existsFn: (p: string) => boolean, lstatFn?: (p: string) => any): void { - vi.mocked(fs.existsSync).mockImplementation((p: any) => existsFn(String(p))) - vi.mocked(fs.lstatSync).mockImplementation((p: any) => { - if (lstatFn) return lstatFn(String(p)) - return String(p).endsWith('.git') ? dirStat : fileStat // Default: .git is a directory - }) - vi.mocked(fs.readdirSync).mockReturnValue([] as any) // Default: empty dirs for findAllGitRepos scanning - vi.mocked(fs.readFileSync).mockReturnValue('') - vi.mocked(fs.writeFileSync).mockImplementation(() => {}) - vi.mocked(fs.mkdirSync).mockImplementation(() => '') -} - -describe('gitExcludeOutputPlugin', () => { - beforeEach(() => vi.clearAllMocks()) - - it('should write to git exclude in projects with merge', async () => { - const plugin = new GitExcludeOutputPlugin() - - const ctx = { - collectedInputContext: { - globalGitIgnore: 'dist/', - workspace: { - directory: {path: '/ws'}, - projects: [ - { - name: 'project1', - dirFromWorkspacePath: { - path: 'project1', - basePath: '/ws', - getAbsolutePath: () => '/ws/project1' - }, - isPromptSourceProject: false - } - ] - } - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as any - - setupFsMocks(p => p.includes('project1') && p.includes('.git')) - - const spy = vi.mocked(fs.writeFileSync) - const result = await plugin.writeProjectOutputs(ctx) - - expect(result.files.length).toBeGreaterThanOrEqual(1) - expect(spy).toHaveBeenCalled() - const firstCall = spy.mock.calls[0] - const writtenContent = (firstCall?.[1] ?? '') as string - expect(writtenContent).toBe('dist/\n') - }) - - it('should skip if no globalGitIgnore and no shadowGitExclude', async () => { - const plugin = new GitExcludeOutputPlugin() - const ctx = { - collectedInputContext: { - workspace: { - directory: {path: '/ws'}, - projects: [] - } - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as any - - const result = await plugin.writeProjectOutputs(ctx) - expect(result.files).toHaveLength(0) - }) - - it('should merge globalGitIgnore and shadowGitExclude', async () => { - const plugin = new GitExcludeOutputPlugin() - - const ctx = { - collectedInputContext: { - globalGitIgnore: 'node_modules/', - shadowGitExclude: '.idea/\n*.log', - workspace: { - directory: {path: '/ws'}, - projects: [ - { - name: 'project1', - dirFromWorkspacePath: { - path: 'project1', - basePath: '/ws', - getAbsolutePath: () => '/ws/project1' - }, - isPromptSourceProject: false - } - ] - } - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as any - - setupFsMocks(p => p.includes('.git')) - - const spy = vi.mocked(fs.writeFileSync) - await plugin.writeProjectOutputs(ctx) - - 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') - }) - - it('should replace existing managed section', async () => { - const plugin = new GitExcludeOutputPlugin() - - const ctx = { - collectedInputContext: { - globalGitIgnore: 'new-content/', - workspace: { - directory: {path: '/ws'}, - projects: [ - { - name: 'project1', - dirFromWorkspacePath: { - path: 'project1', - basePath: '/ws', - getAbsolutePath: () => '/ws/project1' - }, - isPromptSourceProject: false - } - ] - } - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as any - - setupFsMocks(p => p.includes('.git')) - - const spy = vi.mocked(fs.writeFileSync) - await plugin.writeProjectOutputs(ctx) - - const firstCall = spy.mock.calls[0] - const writtenContent = (firstCall?.[1] ?? '') as string - expect(writtenContent).toBe('new-content/\n') - }) - - it('should work with only shadowGitExclude', async () => { - const plugin = new GitExcludeOutputPlugin() - - const ctx = { - collectedInputContext: { - shadowGitExclude: '.cache/', - workspace: { - directory: {path: '/ws'}, - projects: [ - { - name: 'project1', - dirFromWorkspacePath: { - path: 'project1', - basePath: '/ws', - getAbsolutePath: () => '/ws/project1' - }, - isPromptSourceProject: false - } - ] - } - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as any - - setupFsMocks(p => p.includes('.git')) - - const spy = vi.mocked(fs.writeFileSync) - await plugin.writeProjectOutputs(ctx) - - const firstCall = spy.mock.calls[0] - const writtenContent = (firstCall?.[1] ?? '') as string - expect(writtenContent).toContain('.cache/') - }) - - it('should resolve submodule .git file with gitdir pointer', async () => { - const plugin = new GitExcludeOutputPlugin() - - const ctx = { - collectedInputContext: { - globalGitIgnore: '.kiro/', - workspace: { - directory: {path: '/ws'}, - projects: [ - { - name: 'submod', - dirFromWorkspacePath: { - path: 'submod', - basePath: '/ws', - getAbsolutePath: () => '/ws/submod' - }, - isPromptSourceProject: false - } - ] - } - } - } as any - - vi.mocked(fs.existsSync).mockImplementation((p: any) => { - const s = String(p).replaceAll('\\', '/') - return s === '/ws/submod/.git' || s === '/ws/.git' - }) - vi.mocked(fs.lstatSync).mockImplementation((p: any) => { - 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) => { - const s = String(p).replaceAll('\\', '/') - if (s === '/ws/submod/.git') return 'gitdir: ../.git/modules/submod' - return '' - }) - vi.mocked(fs.readdirSync).mockReturnValue([] as any) - 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 () => { - const plugin = new GitExcludeOutputPlugin() - - const ctx = { - collectedInputContext: { - globalGitIgnore: '.kiro/', - workspace: { - directory: {path: '/ws'}, - projects: [] - } - } - } as any - - const infoDirent = {name: 'info', isDirectory: () => true, isFile: () => false} as any - const modADirent = {name: 'modA', isDirectory: () => true, isFile: () => false} as any - const modBDirent = {name: 'modB', isDirectory: () => true, isFile: () => false} as any - - vi.mocked(fs.existsSync).mockImplementation((p: any) => { - 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).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 - }) - 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/packages/plugin-git-exclude/src/GitExcludeOutputPlugin.ts b/packages/plugin-git-exclude/src/GitExcludeOutputPlugin.ts deleted file mode 100644 index a8df3611..00000000 --- a/packages/plugin-git-exclude/src/GitExcludeOutputPlugin.ts +++ /dev/null @@ -1,275 +0,0 @@ -import type { - OutputPluginContext, - OutputWriteContext, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {findAllGitRepos, findGitModuleInfoDirs, resolveGitInfoDir} from '@truenine/plugin-output-shared/utils' -import {FilePathKind} from '@truenine/plugin-shared' - -export class GitExcludeOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('GitExcludeOutputPlugin') - } - - 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 // Skip prompt source projects - - const projectDirPath = project.dirFromWorkspacePath - const projectDir = projectDirPath.getAbsolutePath() - const {basePath} = projectDirPath - const gitRepoDirs = [projectDir, ...findAllGitRepos(projectDir)] // project root + nested submodules/repos - - for (const repoDir of gitRepoDirs) { - const gitInfoDir = resolveGitInfoDir(repoDir) - if (gitInfoDir == null) continue - - const excludeFilePath = path.join(gitInfoDir, 'exclude') - const relExcludePath = path.relative(basePath, excludeFilePath) - - results.push({ - pathKind: FilePathKind.Relative, - path: relExcludePath, - basePath, - getDirectoryName: () => path.basename(repoDir), - getAbsolutePath: () => excludeFilePath - }) - } - } - - const wsDir = ctx.collectedInputContext.workspace.directory.path // Also register .git/modules/ exclude files - const wsDotGit = path.join(wsDir, '.git') - if (fs.existsSync(wsDotGit) && fs.lstatSync(wsDotGit).isDirectory()) { - for (const moduleInfoDir of findGitModuleInfoDirs(wsDotGit)) { - const excludeFilePath = path.join(moduleInfoDir, 'exclude') - const relExcludePath = path.relative(wsDir, excludeFilePath) - - results.push({ - pathKind: FilePathKind.Relative, - path: relExcludePath, - basePath: wsDir, - getDirectoryName: () => path.basename(path.dirname(moduleInfoDir)), - getAbsolutePath: () => excludeFilePath - }) - } - } - - return results - } - - async registerGlobalOutputDirs(): Promise { - return [] // No global directories to clean - } - - async registerGlobalOutputFiles(): Promise { - return [] // No global files to clean - workspace exclude is handled in writeProjectOutputs - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {globalGitIgnore, shadowGitExclude} = ctx.collectedInputContext - const hasContent = (globalGitIgnore != null && globalGitIgnore.length > 0) - || (shadowGitExclude != null && shadowGitExclude.length > 0) - - if (!hasContent) { - this.log.debug({action: 'canWrite', result: false, reason: 'No gitignore or exclude content found'}) - return false - } - - const {projects} = ctx.collectedInputContext.workspace - const hasGitProjects = projects.some(project => { - if (project.dirFromWorkspacePath == null) return false - const projectDir = project.dirFromWorkspacePath.getAbsolutePath() - if (resolveGitInfoDir(projectDir) != null) return true // Check project root - return findAllGitRepos(projectDir).some(d => resolveGitInfoDir(d) != null) // Check nested repos - }) - - const workspaceDir = ctx.collectedInputContext.workspace.directory.path - const hasWorkspaceGit = resolveGitInfoDir(workspaceDir) != null - - const canWrite = hasGitProjects || hasWorkspaceGit - this.log.debug({ - action: 'canWrite', - result: canWrite, - hasGitProjects, - hasWorkspaceGit, - reason: canWrite ? 'Found git repositories to update' : 'No git repositories found' - }) - - return canWrite - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const fileResults: WriteResult[] = [] - const {globalGitIgnore, shadowGitExclude} = ctx.collectedInputContext - - const managedContent = this.buildManagedContent(globalGitIgnore, shadowGitExclude) - - if (managedContent.length === 0) { - this.log.debug({action: 'write', message: 'No gitignore or exclude content found, skipping'}) - return {files: [], dirs: []} - } - - const {workspace} = ctx.collectedInputContext - const {projects} = workspace - const writtenPaths = new Set() // Track written paths to avoid duplicates - - for (const project of projects) { - if (project.dirFromWorkspacePath == null) continue - - const projectDir = project.dirFromWorkspacePath.getAbsolutePath() - const gitRepoDirs = [projectDir, ...findAllGitRepos(projectDir)] // project root + nested submodules/repos - - for (const repoDir of gitRepoDirs) { - const gitInfoDir = resolveGitInfoDir(repoDir) - if (gitInfoDir == null) continue - - const gitInfoExcludePath = path.join(gitInfoDir, 'exclude') - - if (writtenPaths.has(gitInfoExcludePath)) continue - writtenPaths.add(gitInfoExcludePath) - - const label = repoDir === projectDir - ? `project:${project.name ?? 'unknown'}` - : `nested:${path.relative(projectDir, repoDir)}` - - this.log.trace({action: 'write', path: gitInfoExcludePath, label}) - - const result = await this.writeGitExcludeFile(ctx, gitInfoExcludePath, managedContent, label) - fileResults.push(result) - } - } - - const workspaceDir = workspace.directory.path - const workspaceGitInfoDir = resolveGitInfoDir(workspaceDir) // workspace root .git (may also be submodule host) - - if (workspaceGitInfoDir != null) { - const workspaceGitExclude = path.join(workspaceGitInfoDir, 'exclude') - - if (!writtenPaths.has(workspaceGitExclude)) { - this.log.trace({action: 'write', path: workspaceGitExclude, target: 'workspace'}) - const result = await this.writeGitExcludeFile(ctx, workspaceGitExclude, managedContent, 'workspace') - fileResults.push(result) - writtenPaths.add(workspaceGitExclude) - } - } - - const workspaceNestedRepos = findAllGitRepos(workspaceDir) // nested repos under workspace root not covered by projects - for (const repoDir of workspaceNestedRepos) { - const gitInfoDir = resolveGitInfoDir(repoDir) - if (gitInfoDir == null) continue - - const excludePath = path.join(gitInfoDir, 'exclude') - if (writtenPaths.has(excludePath)) continue - writtenPaths.add(excludePath) - - const label = `workspace-nested:${path.relative(workspaceDir, repoDir)}` - this.log.trace({action: 'write', path: excludePath, label}) - - const result = await this.writeGitExcludeFile(ctx, excludePath, managedContent, label) - fileResults.push(result) - } - - const dotGitDir = path.join(workspaceDir, '.git') // Scan .git/modules/ for submodule info dirs - if (fs.existsSync(dotGitDir) && fs.lstatSync(dotGitDir).isDirectory()) { - for (const moduleInfoDir of findGitModuleInfoDirs(dotGitDir)) { - const excludePath = path.join(moduleInfoDir, 'exclude') - if (writtenPaths.has(excludePath)) continue - writtenPaths.add(excludePath) - - const label = `git-module:${path.relative(dotGitDir, moduleInfoDir)}` - this.log.trace({action: 'write', path: excludePath, label}) - - const result = await this.writeGitExcludeFile(ctx, excludePath, managedContent, label) - fileResults.push(result) - } - } - - return {files: fileResults, dirs: []} - } - - private buildManagedContent(globalGitIgnore?: string, shadowGitExclude?: string): string { - const parts: string[] = [] - - if (globalGitIgnore != null && globalGitIgnore.trim().length > 0) { // Handle globalGitIgnore first - const sanitized = this.sanitizeContent(globalGitIgnore) - if (sanitized.length > 0) parts.push(sanitized) - } - - if (shadowGitExclude != null && shadowGitExclude.trim().length > 0) { // Handle shadowGitExclude - const sanitized = this.sanitizeContent(shadowGitExclude) - if (sanitized.length > 0) parts.push(sanitized) - } - - if (parts.length === 0) return '' // Return early if no content was added - return parts.join('\n') - } - - private sanitizeContent(content: string): string { - const lines = content.split(/\r?\n/) - const filtered = lines.filter(line => { - const trimmed = line.trim() - if (trimmed.length === 0) return true - return !(trimmed.startsWith('#') && !trimmed.startsWith('\\#')) - }) - return filtered.join('\n').trim() - } - - private normalizeContent(content: string): string { - const trimmed = content.trim() - if (trimmed.length === 0) return '' - return `${trimmed}\n` - } - - private async writeGitExcludeFile( - ctx: OutputWriteContext, - filePath: string, - managedContent: string, - label: string - ): Promise { - const workspaceDir = ctx.collectedInputContext.workspace.directory.path // Create RelativePath for the result - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: path.relative(workspaceDir, filePath), - basePath: workspaceDir, - getDirectoryName: () => path.basename(path.dirname(filePath)), - getAbsolutePath: () => filePath - } - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'gitExclude', path: filePath, label}) - return {path: relativePath, success: true, skipped: false} - } - - try { - const gitInfoDir = path.dirname(filePath) // Ensure the .git/info directory exists - if (!fs.existsSync(gitInfoDir)) { - fs.mkdirSync(gitInfoDir, {recursive: true}) - this.log.debug({action: 'mkdir', path: gitInfoDir, message: 'Created .git/info directory'}) - } - - const finalContent = this.normalizeContent(managedContent) - - fs.writeFileSync(filePath, finalContent, 'utf8') // Write the exclude file - this.log.trace({action: 'write', type: 'gitExclude', path: filePath, label}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'gitExclude', path: filePath, label, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } -} diff --git a/packages/plugin-git-exclude/src/index.ts b/packages/plugin-git-exclude/src/index.ts deleted file mode 100644 index b4de77a1..00000000 --- a/packages/plugin-git-exclude/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - GitExcludeOutputPlugin -} from './GitExcludeOutputPlugin' diff --git a/packages/plugin-git-exclude/tsconfig.eslint.json b/packages/plugin-git-exclude/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-git-exclude/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-git-exclude/tsconfig.json b/packages/plugin-git-exclude/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-git-exclude/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-git-exclude/tsconfig.lib.json b/packages/plugin-git-exclude/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-git-exclude/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-git-exclude/tsconfig.test.json b/packages/plugin-git-exclude/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-git-exclude/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-git-exclude/tsdown.config.ts b/packages/plugin-git-exclude/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-git-exclude/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-git-exclude/vite.config.ts b/packages/plugin-git-exclude/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-git-exclude/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-git-exclude/vitest.config.ts b/packages/plugin-git-exclude/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-git-exclude/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-agentskills/eslint.config.ts b/packages/plugin-input-agentskills/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-agentskills/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-agentskills/package.json b/packages/plugin-input-agentskills/package.json deleted file mode 100644 index a8139911..00000000 --- a/packages/plugin-input-agentskills/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-agentskills", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-agentskills for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-agentskills/src/SkillInputPlugin.test.ts b/packages/plugin-input-agentskills/src/SkillInputPlugin.test.ts deleted file mode 100644 index be4aee01..00000000 --- a/packages/plugin-input-agentskills/src/SkillInputPlugin.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type {ILogger} from '@truenine/plugin-shared' -import {Buffer} from 'node:buffer' -import * as path from 'node:path' -import {PromptKind} from '@truenine/plugin-shared' -import {describe, expect, it, vi} from 'vitest' -import {SkillInputPlugin} from './SkillInputPlugin' - -describe('skillInputPlugin', () => { - const createMockLogger = (): ILogger => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn() - }) - - describe('readMcpConfig', () => { - const plugin = new SkillInputPlugin() - - it('should return undefined when mcp.json does not exist', () => { - const mockFs = { - existsSync: vi.fn().mockReturnValue(false), - statSync: vi.fn(), - readFileSync: vi.fn() - } as unknown as typeof import('node:fs') - - const result = plugin.readMcpConfig('/skill/dir', mockFs, createMockLogger()) - expect(result).toBeUndefined() - }) - - it('should parse valid mcp.json', () => { - const mcpContent = JSON.stringify({ - mcpServers: { - 'test-server': { - command: 'uvx', - args: ['test-package'], - env: {TEST: 'value'} - } - } - }) - - const mockFs = { - existsSync: vi.fn().mockReturnValue(true), - statSync: vi.fn().mockReturnValue({isFile: () => true}), - readFileSync: vi.fn().mockReturnValue(mcpContent) - } as unknown as typeof import('node:fs') - - const result = plugin.readMcpConfig('/skill/dir', mockFs, createMockLogger()) - - expect(result).toBeDefined() - expect(result?.type).toBe(PromptKind.SkillMcpConfig) - expect(result?.mcpServers['test-server']).toEqual({ - command: 'uvx', - args: ['test-package'], - env: {TEST: 'value'} - }) - }) - - it('should return undefined for invalid JSON', () => { - const mockFs = { - existsSync: vi.fn().mockReturnValue(true), - statSync: vi.fn().mockReturnValue({isFile: () => true}), - readFileSync: vi.fn().mockReturnValue('invalid json') - } as unknown as typeof import('node:fs') - - const logger = createMockLogger() - const result = plugin.readMcpConfig('/skill/dir', mockFs, logger) - - expect(result).toBeUndefined() - expect(logger.warn).toHaveBeenCalled() - }) - - it('should return undefined when mcpServers field is missing', () => { - const mockFs = { - existsSync: vi.fn().mockReturnValue(true), - statSync: vi.fn().mockReturnValue({isFile: () => true}), - readFileSync: vi.fn().mockReturnValue('{}') - } as unknown as typeof import('node:fs') - - const logger = createMockLogger() - const result = plugin.readMcpConfig('/skill/dir', mockFs, logger) - - expect(result).toBeUndefined() - expect(logger.warn).toHaveBeenCalled() - }) - }) - - describe('scanSkillDirectory', () => { - const plugin = new SkillInputPlugin() - - it('should scan child docs and resources at root level', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'skill.mdx', isFile: () => true, isDirectory: () => false}, - {name: 'guide.mdx', isFile: () => true, isDirectory: () => false}, - {name: 'mcp.json', isFile: () => true, isDirectory: () => false}, - {name: 'helper.kt', isFile: () => true, isDirectory: () => false}, - {name: 'logo.png', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockImplementation((filePath: string) => { - if (filePath.endsWith('.mdx')) return '# Content' - if (filePath.endsWith('.png')) return Buffer.from('binary') - return 'code content' - }) - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.childDocs).toHaveLength(1) // Should have 1 child doc (guide.mdx, not skill.mdx) - expect(result.childDocs[0]?.relativePath).toBe('guide.mdx') - expect(result.childDocs[0]?.type).toBe(PromptKind.SkillChildDoc) - - expect(result.resources).toHaveLength(2) // Should have 2 resources (helper.kt, logo.png, not mcp.json) - expect(result.resources.map(r => r.fileName)).toContain('helper.kt') - expect(result.resources.map(r => r.fileName)).toContain('logo.png') - }) - - it('should recursively scan subdirectories', () => { - const skillDir = path.normalize('/skill/dir') - const docsDir = path.join(skillDir, 'docs') - const assetsDir = path.join(skillDir, 'assets') - - const mockFs = { - readdirSync: vi.fn().mockImplementation((dir: string) => { - const normalizedDir = path.normalize(dir) - if (normalizedDir === skillDir) { - return [ - {name: 'docs', isFile: () => false, isDirectory: () => true}, - {name: 'assets', isFile: () => false, isDirectory: () => true} - ] - } - if (normalizedDir === docsDir) { - return [ - {name: 'guide.mdx', isFile: () => true, isDirectory: () => false}, - {name: 'api.mdx', isFile: () => true, isDirectory: () => false} - ] - } - if (normalizedDir === assetsDir) { - return [ - {name: 'logo.png', isFile: () => true, isDirectory: () => false}, - {name: 'schema.sql', isFile: () => true, isDirectory: () => false} - ] - } - return [] - }), - readFileSync: vi.fn().mockImplementation((filePath: string) => { - if (filePath.endsWith('.mdx')) return '# Content' - if (filePath.endsWith('.png')) return Buffer.from('binary') - return 'content' - }) - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory(skillDir, mockFs, createMockLogger()) - - expect(result.childDocs).toHaveLength(2) // Should have 2 child docs from docs/ - const childDocPaths = result.childDocs.map(d => d.relativePath.replaceAll('\\', '/')) // Normalize paths for cross-platform comparison - expect(childDocPaths).toContain('docs/guide.mdx') - expect(childDocPaths).toContain('docs/api.mdx') - - expect(result.resources).toHaveLength(2) // Should have 2 resources from assets/ - const resourcePaths = result.resources.map(r => r.relativePath.replaceAll('\\', '/')) - expect(resourcePaths).toContain('assets/logo.png') - expect(resourcePaths).toContain('assets/schema.sql') - }) - - it('should handle binary files with base64 encoding', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'image.png', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockReturnValue(Buffer.from('binary content')) - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.resources).toHaveLength(1) - expect(result.resources[0]?.encoding).toBe('base64') - expect(result.resources[0]?.category).toBe('image') - }) - - it('should handle text files with UTF-8 encoding', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'helper.kt', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockReturnValue('fun main() {}') - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.resources).toHaveLength(1) - expect(result.resources[0]?.encoding).toBe('text') - expect(result.resources[0]?.category).toBe('code') - expect(result.resources[0]?.content).toBe('fun main() {}') - }) - }) - - describe('.mdx to .md URL transformation in skills', () => { - const plugin = new SkillInputPlugin() - - it('should transform .mdx links to .md in child doc content', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockReturnValue('See [other doc](./other.mdx) for details') - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.childDocs).toHaveLength(1) - expect(result.childDocs[0]?.content).toContain('./other.md') - expect(result.childDocs[0]?.content).not.toContain('.mdx') - }) - - it('should transform .mdx links with anchors', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockReturnValue('[Section](./doc.mdx#section)') - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.childDocs[0]?.content).toContain('./doc.md#section') - }) - - it('should not transform external URLs', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockReturnValue('[External](https://example.com/file.mdx)') - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.childDocs[0]?.content).toContain('https://example.com/file.mdx') - }) - - it('should transform multiple .mdx links in same content', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockReturnValue('[First](./a.mdx) and [Second](./b.mdx)') - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.childDocs[0]?.content).toContain('./a.md') - expect(result.childDocs[0]?.content).toContain('./b.md') - expect(result.childDocs[0]?.content).not.toContain('.mdx') - }) - - it('should transform image references with .mdx extension', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockReturnValue('![Diagram](./diagram.mdx)') - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.childDocs[0]?.content).toContain('./diagram.md') - }) - - it('should preserve non-.mdx links unchanged', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockReturnValue('[Link](./file.md) and [Other](./doc.txt)') - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.childDocs[0]?.content).toContain('./file.md') - expect(result.childDocs[0]?.content).toContain('./doc.txt') - }) - - it('should transform .mdx in link text when it looks like a path', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockReturnValue('[example.mdx](./example.mdx)') - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.childDocs[0]?.content).toBe('[example.md](./example.md)') - }) - - it('should transform .mdx in link text for table markdown links', () => { - const mockFs = { - readdirSync: vi.fn().mockReturnValue([ - {name: 'guide.mdx', isFile: () => true, isDirectory: () => false} - ]), - readFileSync: vi.fn().mockReturnValue('| [examples/example_figma.mdx](examples/example_figma.mdx) |') - } as unknown as typeof import('node:fs') - - const result = plugin.scanSkillDirectory('/skill/dir', mockFs, createMockLogger()) - - expect(result.childDocs[0]?.content).toBe('| [examples/example_figma.md](examples/example_figma.md) |') - }) - }) -}) diff --git a/packages/plugin-input-agentskills/src/SkillInputPlugin.ts b/packages/plugin-input-agentskills/src/SkillInputPlugin.ts deleted file mode 100644 index b8427122..00000000 --- a/packages/plugin-input-agentskills/src/SkillInputPlugin.ts +++ /dev/null @@ -1,476 +0,0 @@ -import type {CollectedInputContext, ILogger, InputPluginContext, McpServerConfig, SkillChildDoc, SkillMcpConfig, SkillPrompt, SkillResource, SkillResourceCategory, SkillResourceEncoding, SkillYAMLFrontMatter} from '@truenine/plugin-shared' - -import {Buffer} from 'node:buffer' -import * as path from 'node:path' -import {mdxToMd} from '@truenine/md-compiler' -import {MetadataValidationError} from '@truenine/md-compiler/errors' -import {parseMarkdown, transformMdxReferencesToMd} from '@truenine/md-compiler/markdown' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind, - PromptKind, - SKILL_RESOURCE_BINARY_EXTENSIONS, - validateSkillMetadata -} from '@truenine/plugin-shared' - -function isBinaryResourceExtension(ext: string): boolean { - return (SKILL_RESOURCE_BINARY_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) -} - -function getResourceCategory(ext: string): SkillResourceCategory { - const lowerExt = ext.toLowerCase() - - const imageExtensions = [ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.ico', - '.bmp', - '.tiff', - '.svg' - ] - if (imageExtensions.includes(lowerExt)) return 'image' - - const codeExtensions = [ - '.kt', - '.java', - '.py', - '.pyi', - '.pyx', - '.ts', - '.tsx', - '.js', - '.jsx', - '.mjs', - '.cjs', - '.go', - '.rs', - '.c', - '.cpp', - '.cc', - '.h', - '.hpp', - '.hxx', - '.cs', - '.fs', - '.fsx', - '.vb', - '.rb', - '.php', - '.swift', - '.scala', - '.groovy', - '.lua', - '.r', - '.jl', - '.ex', - '.exs', - '.erl', - '.clj', - '.cljs', - '.hs', - '.ml', - '.mli', - '.nim', - '.zig', - '.v', - '.dart', - '.vue', - '.svelte', - '.d.ts', - '.d.mts', - '.d.cts' - ] - if (codeExtensions.includes(lowerExt)) return 'code' - - const dataExtensions = [ - '.sql', - '.json', - '.jsonc', - '.json5', - '.xml', - '.xsd', - '.xsl', - '.xslt', - '.yaml', - '.yml', - '.toml', - '.csv', - '.tsv', - '.graphql', - '.gql', - '.proto' - ] - if (dataExtensions.includes(lowerExt)) return 'data' - - const documentExtensions = [ - '.txt', - '.text', - '.rtf', - '.log', - '.docx', - '.doc', - '.xlsx', - '.xls', - '.pptx', - '.ppt', - '.pdf', - '.odt', - '.ods', - '.odp' - ] - if (documentExtensions.includes(lowerExt)) return 'document' - - const configExtensions = [ - '.ini', - '.conf', - '.cfg', - '.config', - '.properties', - '.env', - '.envrc', - '.editorconfig', - '.gitignore', - '.gitattributes', - '.npmrc', - '.nvmrc', - '.npmignore', - '.eslintrc', - '.prettierrc', - '.stylelintrc', - '.babelrc', - '.browserslistrc' - ] - if (configExtensions.includes(lowerExt)) return 'config' - - const scriptExtensions = [ - '.sh', - '.bash', - '.zsh', - '.fish', - '.ps1', - '.psm1', - '.psd1', - '.bat', - '.cmd' - ] - if (scriptExtensions.includes(lowerExt)) return 'script' - - const binaryExtensions = [ - '.exe', - '.dll', - '.so', - '.dylib', - '.bin', - '.wasm', - '.class', - '.jar', - '.war', - '.pyd', - '.pyc', - '.pyo', - '.zip', - '.tar', - '.gz', - '.bz2', - '.7z', - '.rar', - '.ttf', - '.otf', - '.woff', - '.woff2', - '.eot', - '.db', - '.sqlite', - '.sqlite3' - ] - if (binaryExtensions.includes(lowerExt)) return 'binary' - - return 'other' -} - -function getMimeType(ext: string): string | void { - const mimeTypes: Record = { - '.ts': 'text/typescript', - '.tsx': 'text/typescript', - '.js': 'text/javascript', - '.jsx': 'text/javascript', - '.json': 'application/json', - '.py': 'text/x-python', - '.java': 'text/x-java', - '.kt': 'text/x-kotlin', - '.go': 'text/x-go', - '.rs': 'text/x-rust', - '.c': 'text/x-c', - '.cpp': 'text/x-c++', - '.cs': 'text/x-csharp', - '.rb': 'text/x-ruby', - '.php': 'text/x-php', - '.swift': 'text/x-swift', - '.scala': 'text/x-scala', - '.sql': 'application/sql', - '.xml': 'application/xml', - '.yaml': 'text/yaml', - '.yml': 'text/yaml', - '.toml': 'text/toml', - '.csv': 'text/csv', - '.graphql': 'application/graphql', - '.txt': 'text/plain', - '.pdf': 'application/pdf', - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - '.html': 'text/html', - '.css': 'text/css', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.ico': 'image/x-icon', - '.bmp': 'image/bmp' - } - return mimeTypes[ext.toLowerCase()] -} - -export class SkillInputPlugin extends AbstractInputPlugin { - constructor() { - super('SkillInputPlugin') - } - - readMcpConfig( - skillDir: string, - fs: typeof import('node:fs'), - logger: ILogger - ): SkillMcpConfig | void { - const mcpJsonPath = path.join(skillDir, 'mcp.json') - - if (!fs.existsSync(mcpJsonPath)) return void 0 - - if (!fs.statSync(mcpJsonPath).isFile()) { - logger.warn('mcp.json is not a file', {skillDir}) - return void 0 - } - - try { - const rawContent = fs.readFileSync(mcpJsonPath, 'utf8') - const parsed = JSON.parse(rawContent) as {mcpServers?: Record} - - if (parsed.mcpServers == null || typeof parsed.mcpServers !== 'object') { - logger.warn('mcp.json missing mcpServers field', {skillDir}) - return void 0 - } - - return { - type: PromptKind.SkillMcpConfig, - mcpServers: parsed.mcpServers, - rawContent - } - } - catch (e) { - logger.warn('failed to parse mcp.json', {skillDir, error: e}) - return void 0 - } - } - - scanSkillDirectory( - skillDir: string, - fs: typeof import('node:fs'), - logger: ILogger, - currentRelativePath: string = '' - ): {childDocs: SkillChildDoc[], resources: SkillResource[]} { - const childDocs: SkillChildDoc[] = [] - const resources: SkillResource[] = [] - - const currentDir = currentRelativePath - ? path.join(skillDir, currentRelativePath) - : skillDir - - try { - const entries = fs.readdirSync(currentDir, {withFileTypes: true}) - - for (const entry of entries) { - const relativePath = currentRelativePath - ? `${currentRelativePath}/${entry.name}` - : entry.name - - if (entry.isDirectory()) { - const subResult = this.scanSkillDirectory(skillDir, fs, logger, relativePath) - childDocs.push(...subResult.childDocs) - resources.push(...subResult.resources) - } else if (entry.isFile()) { - const filePath = path.join(currentDir, entry.name) - - if (entry.name.endsWith('.mdx')) { - if (currentRelativePath === '' && entry.name === 'skill.mdx') continue - - try { - const rawContent = fs.readFileSync(filePath, 'utf8') - const parsed = parseMarkdown(rawContent) - const content = transformMdxReferencesToMd(parsed.contentWithoutFrontMatter) - - childDocs.push({ - type: PromptKind.SkillChildDoc, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - relativePath, - dir: { - pathKind: FilePathKind.Relative, - path: relativePath, - basePath: skillDir, - getDirectoryName: () => path.dirname(relativePath), - getAbsolutePath: () => filePath - } - } as SkillChildDoc) - } - catch (e) { - logger.warn('failed to read child doc', {path: relativePath, error: e}) - } - } else { - if (currentRelativePath === '' && entry.name === 'mcp.json') continue - - const ext = path.extname(entry.name) - let content: string, - encoding: SkillResourceEncoding, - length: number - - try { - if (isBinaryResourceExtension(ext)) { - const buffer = fs.readFileSync(filePath) - content = buffer.toString('base64') - encoding = 'base64' - ;({length} = buffer) - } else { - content = fs.readFileSync(filePath, 'utf8') - encoding = 'text' - ;({length} = Buffer.from(content, 'utf8')) - } - - const mimeType = getMimeType(ext) - const resource: SkillResource = { - type: PromptKind.SkillResource, - extension: ext, - fileName: entry.name, - relativePath, - content, - encoding, - category: getResourceCategory(ext), - length - } - - if (mimeType != null) resources.push({...resource, mimeType}) - else resources.push(resource) - } - catch (e) { - logger.warn('failed to read resource file', {path: relativePath, error: e}) - } - } - } - } - } - catch (e) { - logger.warn('failed to scan directory', {path: currentDir, error: e}) - } - - return {childDocs, resources} - } - - async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger, globalScope} = ctx - const {shadowProjectDir} = this.resolveBasePaths(options) - - const skillDir = this.resolveShadowPath(options.shadowSourceProject.skill.dist, shadowProjectDir) - - const skills: SkillPrompt[] = [] - if (!(ctx.fs.existsSync(skillDir) && ctx.fs.statSync(skillDir).isDirectory())) return {skills} - - const entries = ctx.fs.readdirSync(skillDir, {withFileTypes: true}) - for (const entry of entries) { - if (entry.isDirectory()) { - const skillFilePath = ctx.path.join(skillDir, entry.name, 'skill.mdx') - if (ctx.fs.existsSync(skillFilePath) && ctx.fs.statSync(skillFilePath).isFile()) { - try { - const rawContent = ctx.fs.readFileSync(skillFilePath, 'utf8') - - const parsed = parseMarkdown(rawContent) - - const compileResult = await mdxToMd(rawContent, { - globalScope, - extractMetadata: true, - basePath: ctx.path.join(skillDir, entry.name) - }) - - const mergedFrontMatter: SkillYAMLFrontMatter = { - ...parsed.yamlFrontMatter, - ...compileResult.metadata.fields - } as SkillYAMLFrontMatter - - const validationResult = validateSkillMetadata( - mergedFrontMatter as Record, - skillFilePath - ) - - for (const warning of validationResult.warnings) logger.debug(warning) - - if (!validationResult.valid) throw new MetadataValidationError(validationResult.errors, skillFilePath) - - const content = transformMdxReferencesToMd(compileResult.content) - - const skillAbsoluteDir = ctx.path.join(skillDir, entry.name) - - const mcpConfig = this.readMcpConfig(skillAbsoluteDir, ctx.fs, logger) - - const {childDocs, resources} = this.scanSkillDirectory( - skillAbsoluteDir, - ctx.fs, - logger - ) - - logger.debug('skill metadata extracted', { - skill: entry.name, - source: compileResult.metadata.source, - hasYaml: parsed.yamlFrontMatter != null, - hasExport: Object.keys(compileResult.metadata.fields).length > 0 - }) - - const {seriName} = mergedFrontMatter - - skills.push({ - type: PromptKind.Skill, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - yamlFrontMatter: mergedFrontMatter.name != null - ? mergedFrontMatter - : {name: entry.name, description: ''} as SkillYAMLFrontMatter, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - ...mcpConfig != null && {mcpConfig}, - ...childDocs.length > 0 && {childDocs}, - ...resources.length > 0 && {resources}, - ...seriName != null && {seriName}, - dir: { - pathKind: FilePathKind.Relative, - path: entry.name, - basePath: skillDir, - getDirectoryName: () => entry.name, - getAbsolutePath: () => path.join(skillDir, entry.name) - } - }) - } - catch (e) { - logger.error('failed to parse skill', {file: skillFilePath, error: e}) - } - } - } - } - return {skills} - } -} diff --git a/packages/plugin-input-agentskills/src/index.ts b/packages/plugin-input-agentskills/src/index.ts deleted file mode 100644 index 25f6e244..00000000 --- a/packages/plugin-input-agentskills/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - SkillInputPlugin -} from './SkillInputPlugin' diff --git a/packages/plugin-input-agentskills/tsconfig.eslint.json b/packages/plugin-input-agentskills/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-agentskills/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-agentskills/tsconfig.json b/packages/plugin-input-agentskills/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-agentskills/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-agentskills/tsconfig.lib.json b/packages/plugin-input-agentskills/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-agentskills/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-agentskills/tsconfig.test.json b/packages/plugin-input-agentskills/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-agentskills/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-agentskills/tsdown.config.ts b/packages/plugin-input-agentskills/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-agentskills/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-agentskills/vite.config.ts b/packages/plugin-input-agentskills/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-agentskills/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-agentskills/vitest.config.ts b/packages/plugin-input-agentskills/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-agentskills/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-editorconfig/eslint.config.ts b/packages/plugin-input-editorconfig/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-editorconfig/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-editorconfig/package.json b/packages/plugin-input-editorconfig/package.json deleted file mode 100644 index 0d7a08bb..00000000 --- a/packages/plugin-input-editorconfig/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-input-editorconfig", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-editorconfig for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-editorconfig/src/EditorConfigInputPlugin.ts b/packages/plugin-input-editorconfig/src/EditorConfigInputPlugin.ts deleted file mode 100644 index 3c74eba8..00000000 --- a/packages/plugin-input-editorconfig/src/EditorConfigInputPlugin.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '@truenine/plugin-shared' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {FilePathKind, IDEKind} from '@truenine/plugin-shared' - -function readIdeConfigFile( - type: T, - relativePath: string, - shadowProjectDir: string, - fs: typeof import('node:fs'), - path: typeof import('node:path') -): ProjectIDEConfigFile | undefined { - const absPath = path.join(shadowProjectDir, relativePath) - if (!(fs.existsSync(absPath) && fs.statSync(absPath).isFile())) return void 0 - - const content = fs.readFileSync(absPath, 'utf8') - return { - type, - content, - length: content.length, - filePathKind: FilePathKind.Absolute, - dir: { - pathKind: FilePathKind.Absolute, - path: absPath, - getDirectoryName: () => path.basename(absPath) - } - } -} - -export class EditorConfigInputPlugin extends AbstractInputPlugin { - constructor() { - super('EditorConfigInputPlugin') - } - - collect(ctx: InputPluginContext): Partial { - const {userConfigOptions, fs, path} = ctx - const {shadowProjectDir} = this.resolveBasePaths(userConfigOptions) - - const editorConfigFiles: ProjectIDEConfigFile[] = [] - const file = readIdeConfigFile(IDEKind.EditorConfig, '.editorconfig', shadowProjectDir, fs, path) - if (file != null) editorConfigFiles.push(file) - - return {editorConfigFiles} - } -} diff --git a/packages/plugin-input-editorconfig/src/index.ts b/packages/plugin-input-editorconfig/src/index.ts deleted file mode 100644 index 87495147..00000000 --- a/packages/plugin-input-editorconfig/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - EditorConfigInputPlugin -} from './EditorConfigInputPlugin' diff --git a/packages/plugin-input-editorconfig/tsconfig.eslint.json b/packages/plugin-input-editorconfig/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-editorconfig/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-editorconfig/tsconfig.json b/packages/plugin-input-editorconfig/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-editorconfig/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-editorconfig/tsconfig.lib.json b/packages/plugin-input-editorconfig/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-editorconfig/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-editorconfig/tsconfig.test.json b/packages/plugin-input-editorconfig/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-editorconfig/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-editorconfig/tsdown.config.ts b/packages/plugin-input-editorconfig/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-editorconfig/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-editorconfig/vite.config.ts b/packages/plugin-input-editorconfig/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-editorconfig/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-editorconfig/vitest.config.ts b/packages/plugin-input-editorconfig/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-editorconfig/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-fast-command/eslint.config.ts b/packages/plugin-input-fast-command/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-fast-command/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-fast-command/package.json b/packages/plugin-input-fast-command/package.json deleted file mode 100644 index bb9b683c..00000000 --- a/packages/plugin-input-fast-command/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-fast-command", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-fast-command for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-fast-command/src/FastCommandInputPlugin.test.ts b/packages/plugin-input-fast-command/src/FastCommandInputPlugin.test.ts deleted file mode 100644 index c223fd7e..00000000 --- a/packages/plugin-input-fast-command/src/FastCommandInputPlugin.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' -import {FastCommandInputPlugin} from './FastCommandInputPlugin' - -describe('fastCommandInputPlugin', () => { - describe('extractSeriesInfo', () => { - const plugin = new FastCommandInputPlugin() - - it('should derive series from parentDirName when provided', () => { - 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)) - - fc.assert( - fc.property( - alphanumericNoUnderscore, - alphanumericCommandName, - (parentDir, commandName) => { - const fileName = `${commandName}.mdx` - const result = plugin.extractSeriesInfo(fileName, parentDir) - - expect(result.series).toBe(parentDir) - expect(result.commandName).toBe(commandName) - } - ), - {numRuns: 100} - ) - }) - - it('should handle pe/compile.cn.mdx subdirectory format', () => { - const result = plugin.extractSeriesInfo('compile.cn.mdx', 'pe') - expect(result.series).toBe('pe') - expect(result.commandName).toBe('compile.cn') - }) - - it('should handle sk/skill-builder.cn.mdx subdirectory format', () => { - const result = plugin.extractSeriesInfo('skill-builder.cn.mdx', 'sk') - expect(result.series).toBe('sk') - expect(result.commandName).toBe('skill-builder.cn') - }) - - it('should extract series as substring before first underscore for filenames with underscore', () => { - const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z0-9]+$/i.test(s)) - - const alphanumericWithUnderscore = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^\w+$/.test(s)) - - fc.assert( - fc.property( - alphanumericNoUnderscore, - alphanumericWithUnderscore, - (seriesPrefix, commandName) => { - const fileName = `${seriesPrefix}_${commandName}.mdx` - const result = plugin.extractSeriesInfo(fileName) - - expect(result.series).toBe(seriesPrefix) - expect(result.commandName).toBe(commandName) - } - ), - {numRuns: 100} - ) - }) - - it('should return undefined series for filenames without underscore', () => { - const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z0-9]+$/i.test(s)) - - fc.assert( - fc.property( - alphanumericNoUnderscore, - baseName => { - const fileName = `${baseName}.mdx` - const result = plugin.extractSeriesInfo(fileName) - - expect(result.series).toBeUndefined() - expect(result.commandName).toBe(baseName) - } - ), - {numRuns: 100} - ) - }) - - it('should use only first underscore as delimiter', () => { - const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z0-9]+$/i.test(s)) - - fc.assert( - fc.property( - alphanumericNoUnderscore, - alphanumericNoUnderscore, - alphanumericNoUnderscore, - (seriesPrefix, part1, part2) => { - const fileName = `${seriesPrefix}_${part1}_${part2}.mdx` - const result = plugin.extractSeriesInfo(fileName) - - expect(result.series).toBe(seriesPrefix) - expect(result.commandName).toBe(`${part1}_${part2}`) - } - ), - {numRuns: 100} - ) - }) - - it('should handle pe_compile.mdx correctly', () => { - const result = plugin.extractSeriesInfo('pe_compile.mdx') - expect(result.series).toBe('pe') - expect(result.commandName).toBe('compile') - }) - - it('should handle compile.mdx correctly (no underscore)', () => { - const result = plugin.extractSeriesInfo('compile.mdx') - expect(result.series).toBeUndefined() - expect(result.commandName).toBe('compile') - }) - - it('should handle pe_compile_all.mdx correctly (multiple underscores)', () => { - const result = plugin.extractSeriesInfo('pe_compile_all.mdx') - expect(result.series).toBe('pe') - expect(result.commandName).toBe('compile_all') - }) - - it('should handle _compile.mdx correctly (empty prefix)', () => { - const result = plugin.extractSeriesInfo('_compile.mdx') - expect(result.series).toBe('') - expect(result.commandName).toBe('compile') - }) - }) -}) diff --git a/packages/plugin-input-fast-command/src/FastCommandInputPlugin.ts b/packages/plugin-input-fast-command/src/FastCommandInputPlugin.ts deleted file mode 100644 index d9601fc9..00000000 --- a/packages/plugin-input-fast-command/src/FastCommandInputPlugin.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' -import type { - CollectedInputContext, - FastCommandPrompt, - FastCommandYAMLFrontMatter, - InputPluginContext, - MetadataValidationResult, - PluginOptions, - ResolvedBasePaths -} from '@truenine/plugin-shared' -import {mdxToMd} from '@truenine/md-compiler' -import {MetadataValidationError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {BaseDirectoryInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind, - PromptKind, - validateFastCommandMetadata -} from '@truenine/plugin-shared' - -export interface SeriesInfo { - readonly series?: string - readonly commandName: string -} - -export class FastCommandInputPlugin extends BaseDirectoryInputPlugin { - constructor() { - super('FastCommandInputPlugin', {configKey: 'shadowSourceProject.fastCommand.dist'}) - } - - protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { - return this.resolveShadowPath(options.shadowSourceProject.fastCommand.dist, resolvedPaths.shadowProjectDir) - } - - protected validateMetadata(metadata: Record, filePath: string): MetadataValidationResult { - return validateFastCommandMetadata(metadata, filePath) - } - - protected createResult(items: FastCommandPrompt[]): Partial { - return {fastCommands: items} - } - - extractSeriesInfo(fileName: string, parentDirName?: string): SeriesInfo { - const baseName = fileName.replace(/\.mdx$/, '') - - if (parentDirName != null) { - return { - series: parentDirName, - commandName: baseName - } - } - - const underscoreIndex = baseName.indexOf('_') - - if (underscoreIndex === -1) return {commandName: baseName} - - return { - series: baseName.slice(0, Math.max(0, underscoreIndex)), - commandName: baseName.slice(Math.max(0, underscoreIndex + 1)) - } - } - - 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: FastCommandPrompt[] = [] - - 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.isFile() && entry.name.endsWith(this.extension)) { - const prompt = await this.processFile(entry.name, path.join(targetDir, entry.name), targetDir, void 0, ctx) - if (prompt != null) items.push(prompt) - } else 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 | undefined, - 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: parentDirName != null ? ctx.path.join(baseDir, parentDirName) : baseDir - }) - - const mergedFrontMatter: FastCommandYAMLFrontMatter | undefined = parsed.yamlFrontMatter != null || Object.keys(compileResult.metadata.fields).length > 0 - ? { - ...parsed.yamlFrontMatter, - ...compileResult.metadata.fields - } as FastCommandYAMLFrontMatter - : 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 != null ? `${parentDirName}/${fileName}` : 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 - } - } - - protected createPrompt( - entryName: string, - filePath: string, - content: string, - yamlFrontMatter: FastCommandYAMLFrontMatter | undefined, - rawFrontMatter: string | undefined, - parsed: ParsedMarkdown, - baseDir: string, - rawContent: string - ): FastCommandPrompt { - const slashIndex = entryName.indexOf('/') - const parentDirName = slashIndex !== -1 ? entryName.slice(0, slashIndex) : void 0 - const fileName = slashIndex !== -1 ? entryName.slice(slashIndex + 1) : entryName - - const seriesInfo = this.extractSeriesInfo(fileName, parentDirName) - const seriName = yamlFrontMatter?.seriName - - return { - type: PromptKind.FastCommand, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - ...yamlFrontMatter != null && {yamlFrontMatter}, - ...rawFrontMatter != null && {rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - dir: { - pathKind: FilePathKind.Relative, - path: entryName, - basePath: baseDir, - getDirectoryName: () => entryName.replace(/\.mdx$/, ''), - getAbsolutePath: () => filePath - }, - ...seriesInfo.series != null && {series: seriesInfo.series}, - commandName: seriesInfo.commandName, - ...seriName != null && {seriName}, - rawMdxContent: rawContent - } - } -} diff --git a/packages/plugin-input-fast-command/src/index.ts b/packages/plugin-input-fast-command/src/index.ts deleted file mode 100644 index 3bf19feb..00000000 --- a/packages/plugin-input-fast-command/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - FastCommandInputPlugin -} from './FastCommandInputPlugin' -export type { - SeriesInfo -} from './FastCommandInputPlugin' diff --git a/packages/plugin-input-fast-command/tsconfig.eslint.json b/packages/plugin-input-fast-command/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-fast-command/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-fast-command/tsconfig.json b/packages/plugin-input-fast-command/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-fast-command/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-fast-command/tsconfig.lib.json b/packages/plugin-input-fast-command/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-fast-command/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-fast-command/tsconfig.test.json b/packages/plugin-input-fast-command/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-fast-command/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-fast-command/tsdown.config.ts b/packages/plugin-input-fast-command/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-fast-command/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-fast-command/vite.config.ts b/packages/plugin-input-fast-command/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-fast-command/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-fast-command/vitest.config.ts b/packages/plugin-input-fast-command/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-fast-command/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-git-exclude/eslint.config.ts b/packages/plugin-input-git-exclude/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-git-exclude/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-git-exclude/package.json b/packages/plugin-input-git-exclude/package.json deleted file mode 100644 index 4772baa6..00000000 --- a/packages/plugin-input-git-exclude/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-input-git-exclude", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-git-exclude for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-git-exclude/src/GitExcludeInputPlugin.test.ts b/packages/plugin-input-git-exclude/src/GitExcludeInputPlugin.test.ts deleted file mode 100644 index 26d24faa..00000000 --- a/packages/plugin-input-git-exclude/src/GitExcludeInputPlugin.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type {InputPluginContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import {createLogger} from '@truenine/plugin-shared' -import {beforeEach, describe, expect, it, vi} from 'vitest' -import {GitExcludeInputPlugin} from './GitExcludeInputPlugin' - -vi.mock('node:fs') - -const BASE_OPTIONS = { - workspaceDir: '/workspace', - shadowSourceProject: { - name: 'tnmsc-shadow', - skill: {src: 'src/skills', dist: 'dist/skills'}, - fastCommand: {src: 'src/commands', dist: 'dist/commands'}, - subAgent: {src: 'src/agents', dist: 'dist/agents'}, - rule: {src: 'src/rules', dist: 'dist/rules'}, - globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, - workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, - project: {src: 'app', dist: 'dist/app'} - }, - logLevel: 'debug' -} - -describe('gitExcludeInputPlugin', () => { - beforeEach(() => vi.clearAllMocks()) - - it('should collect exclude content from file if it exists', () => { - const plugin = new GitExcludeInputPlugin() - const ctx = { - logger: createLogger('test', 'debug'), - fs, - userConfigOptions: BASE_OPTIONS - } as unknown as InputPluginContext - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue('.idea/\n*.log') - - const result = plugin.collect(ctx) - - expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringMatching(/public[/\\]exclude/), 'utf8') - expect(result).toEqual({ - shadowGitExclude: '.idea/\n*.log' - }) - }) - - it('should return empty object if file does not exist', () => { - const plugin = new GitExcludeInputPlugin() - const ctx = { - logger: createLogger('test', 'debug'), - fs, - userConfigOptions: BASE_OPTIONS - } as unknown as InputPluginContext - - vi.mocked(fs.existsSync).mockReturnValue(false) - - const result = plugin.collect(ctx) - - expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/public[/\\]exclude/)) - expect(fs.readFileSync).not.toHaveBeenCalled() - expect(result).toEqual({}) - }) - - it('should return empty object if file is empty', () => { - const plugin = new GitExcludeInputPlugin() - const ctx = { - logger: createLogger('test', 'debug'), - fs, - userConfigOptions: BASE_OPTIONS - } as unknown as InputPluginContext - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue('') - - const result = plugin.collect(ctx) - - expect(result).toEqual({}) - }) -}) diff --git a/packages/plugin-input-git-exclude/src/GitExcludeInputPlugin.ts b/packages/plugin-input-git-exclude/src/GitExcludeInputPlugin.ts deleted file mode 100644 index 1f2560f9..00000000 --- a/packages/plugin-input-git-exclude/src/GitExcludeInputPlugin.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {CollectedInputContext} from '@truenine/plugin-shared' -import * as path from 'node:path' -import {BaseFileInputPlugin} from '@truenine/plugin-input-shared' - -/** - * Input plugin that reads git exclude patterns from shadow source project. - * Reads from `public/exclude` file in the shadow project directory. - * - * This content will be merged with existing `.git/info/exclude` by GitExcludeOutputPlugin. - */ -export class GitExcludeInputPlugin extends BaseFileInputPlugin { - constructor() { - super('GitExcludeInputPlugin') - } - - protected getFilePath(shadowProjectDir: string): string { - return path.join(shadowProjectDir, 'public', 'exclude') - } - - protected getResultKey(): keyof CollectedInputContext { - return 'shadowGitExclude' - } -} diff --git a/packages/plugin-input-git-exclude/src/index.ts b/packages/plugin-input-git-exclude/src/index.ts deleted file mode 100644 index 7072ab18..00000000 --- a/packages/plugin-input-git-exclude/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - GitExcludeInputPlugin -} from './GitExcludeInputPlugin' diff --git a/packages/plugin-input-git-exclude/tsconfig.eslint.json b/packages/plugin-input-git-exclude/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-git-exclude/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-git-exclude/tsconfig.json b/packages/plugin-input-git-exclude/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-git-exclude/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-git-exclude/tsconfig.lib.json b/packages/plugin-input-git-exclude/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-git-exclude/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-git-exclude/tsconfig.test.json b/packages/plugin-input-git-exclude/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-git-exclude/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-git-exclude/tsdown.config.ts b/packages/plugin-input-git-exclude/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-git-exclude/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-git-exclude/vite.config.ts b/packages/plugin-input-git-exclude/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-git-exclude/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-git-exclude/vitest.config.ts b/packages/plugin-input-git-exclude/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-git-exclude/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-gitignore/eslint.config.ts b/packages/plugin-input-gitignore/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-gitignore/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-gitignore/package.json b/packages/plugin-input-gitignore/package.json deleted file mode 100644 index d93458f8..00000000 --- a/packages/plugin-input-gitignore/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-gitignore", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-gitignore for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/init-bundle": "workspace:*", - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-gitignore/src/GitIgnoreInputPlugin.test.ts b/packages/plugin-input-gitignore/src/GitIgnoreInputPlugin.test.ts deleted file mode 100644 index 26304fcb..00000000 --- a/packages/plugin-input-gitignore/src/GitIgnoreInputPlugin.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type {InputPluginContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {createLogger} from '@truenine/plugin-shared' -import {beforeEach, describe, expect, it, vi} from 'vitest' -import {GitIgnoreInputPlugin} from './GitIgnoreInputPlugin' - -vi.mock('node:fs') - -const BASE_OPTIONS = { - workspaceDir: '/workspace', - shadowSourceProject: { - name: 'tnmsc-shadow', - skill: {src: 'src/skills', dist: 'dist/skills'}, - fastCommand: {src: 'src/commands', dist: 'dist/commands'}, - subAgent: {src: 'src/agents', dist: 'dist/agents'}, - rule: {src: 'src/rules', dist: 'dist/rules'}, - globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, - workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, - project: {src: 'app', dist: 'dist/app'} - }, - logLevel: 'debug' -} - -describe('gitIgnoreInputPlugin', () => { - beforeEach(() => vi.clearAllMocks()) - - it('should collect gitignore content from file if it exists', () => { - const plugin = new GitIgnoreInputPlugin() - const ctx = { - logger: createLogger('test', 'debug'), - fs, - userConfigOptions: BASE_OPTIONS - } as unknown as InputPluginContext - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue('node_modules/\n.env') - - const result = plugin.collect(ctx) - - expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining(path.join('public', 'gitignore')), 'utf8') - expect(result).toEqual({ - globalGitIgnore: 'node_modules/\n.env' - }) - }) - - it('should fallback to template if file does not exist', () => { - const plugin = new GitIgnoreInputPlugin() - const ctx = { - logger: createLogger('test', 'debug'), - fs, - userConfigOptions: BASE_OPTIONS - } as unknown as InputPluginContext - - vi.mocked(fs.existsSync).mockReturnValue(false) - - const result = plugin.collect(ctx) - - expect(fs.existsSync).toHaveBeenCalledWith(expect.stringContaining(path.join('public', 'gitignore'))) - expect(fs.readFileSync).not.toHaveBeenCalled() - - if (result.globalGitIgnore != null && result.globalGitIgnore.length > 0) { // Plugin uses @truenine/init-bundle template as fallback — may or may not have content - expect(result).toHaveProperty('globalGitIgnore') - } else expect(result).toEqual({}) - }) -}) diff --git a/packages/plugin-input-gitignore/src/GitIgnoreInputPlugin.ts b/packages/plugin-input-gitignore/src/GitIgnoreInputPlugin.ts deleted file mode 100644 index ffb80a00..00000000 --- a/packages/plugin-input-gitignore/src/GitIgnoreInputPlugin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type {CollectedInputContext} from '@truenine/plugin-shared' -import * as path from 'node:path' -import {bundles} from '@truenine/init-bundle' -import {BaseFileInputPlugin} from '@truenine/plugin-input-shared' - -type BundleMap = Readonly> -const bundleMap = bundles as unknown as BundleMap - -function getGitignoreTemplate(): string | undefined { // 从 bundles 获取 gitignore 模板内容(public/exclude) - return bundleMap['public/gitignore']?.content -} - -/** - * Input plugin that reads gitignore content from shadow source project. - * Falls back to template from init-bundle if file doesn't exist. - */ -export class GitIgnoreInputPlugin extends BaseFileInputPlugin { - constructor() { - const template = getGitignoreTemplate() - super('GitIgnoreInputPlugin', template != null ? {fallbackContent: template} : {}) - } - - protected getFilePath(shadowProjectDir: string): string { - return path.join(shadowProjectDir, 'public', 'gitignore') - } - - protected getResultKey(): keyof CollectedInputContext { - return 'globalGitIgnore' - } -} diff --git a/packages/plugin-input-gitignore/src/index.ts b/packages/plugin-input-gitignore/src/index.ts deleted file mode 100644 index 2a4ce65d..00000000 --- a/packages/plugin-input-gitignore/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - GitIgnoreInputPlugin -} from './GitIgnoreInputPlugin' diff --git a/packages/plugin-input-gitignore/tsconfig.eslint.json b/packages/plugin-input-gitignore/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-gitignore/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-gitignore/tsconfig.json b/packages/plugin-input-gitignore/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-gitignore/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-gitignore/tsconfig.lib.json b/packages/plugin-input-gitignore/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-gitignore/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-gitignore/tsconfig.test.json b/packages/plugin-input-gitignore/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-gitignore/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-gitignore/tsdown.config.ts b/packages/plugin-input-gitignore/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-gitignore/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-gitignore/vite.config.ts b/packages/plugin-input-gitignore/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-gitignore/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-gitignore/vitest.config.ts b/packages/plugin-input-gitignore/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-gitignore/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-global-memory/eslint.config.ts b/packages/plugin-input-global-memory/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-global-memory/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-global-memory/package.json b/packages/plugin-input-global-memory/package.json deleted file mode 100644 index 6b556e6f..00000000 --- a/packages/plugin-input-global-memory/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-global-memory", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-global-memory for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-global-memory/src/GlobalMemoryInputPlugin.ts b/packages/plugin-input-global-memory/src/GlobalMemoryInputPlugin.ts deleted file mode 100644 index e4873da7..00000000 --- a/packages/plugin-input-global-memory/src/GlobalMemoryInputPlugin.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type {CollectedInputContext, InputPluginContext} from '@truenine/plugin-shared' - -import * as os from 'node:os' -import process from 'node:process' - -import {mdxToMd} from '@truenine/md-compiler' -import {ScopeError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind, - GlobalConfigDirectoryType, - PromptKind -} from '@truenine/plugin-shared' - -export class GlobalMemoryInputPlugin extends AbstractInputPlugin { - constructor() { - super('GlobalMemoryInputPlugin') - } - - async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, fs, path, globalScope} = ctx - const {shadowProjectDir} = this.resolveBasePaths(options) - - const globalMemoryFile = this.resolveShadowPath(options.shadowSourceProject.globalMemory.dist, shadowProjectDir) - - if (!fs.existsSync(globalMemoryFile)) { - this.log.warn({action: 'collect', reason: 'fileNotFound', path: globalMemoryFile}) - return {} - } - - if (!fs.statSync(globalMemoryFile).isFile()) { - this.log.warn({action: 'collect', reason: 'notAFile', path: globalMemoryFile}) - return {} - } - - const rawContent = fs.readFileSync(globalMemoryFile, 'utf8') - const parsed = parseMarkdown(rawContent) - - let compiledContent: string // Only compile if globalScope is provided, otherwise use raw content // Compile MDX with globalScope to evaluate expressions like {profile.name} - if (globalScope != null) { - try { - compiledContent = await mdxToMd(rawContent, {globalScope, basePath: path.dirname(globalMemoryFile)}) - } - catch (e) { - if (e instanceof ScopeError) { - this.log.error(`MDX compilation failed: ${e.message}`) - this.log.error(`Please check your configuration file (~/.aindex/.tnmsc.json) and ensure all required variables are defined.`) - this.log.error(`For example, if using {profile.name}, add a "profile" section with "name" field to your config.`) - process.exit(1) - } - throw e - } - } else compiledContent = parsed.contentWithoutFrontMatter - - this.log.debug({action: 'collect', path: globalMemoryFile, contentLength: compiledContent.length}) - - return { - globalMemory: { - type: PromptKind.GlobalMemory, - content: compiledContent, - length: compiledContent.length, - filePathKind: FilePathKind.Relative, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - dir: { - pathKind: FilePathKind.Relative, - path: path.basename(globalMemoryFile), - basePath: path.dirname(globalMemoryFile), - getDirectoryName: () => path.basename(globalMemoryFile), - getAbsolutePath: () => globalMemoryFile - }, - parentDirectoryPath: { - type: GlobalConfigDirectoryType.UserHome, - directory: { - pathKind: FilePathKind.Relative, - path: '', - basePath: os.homedir(), - getDirectoryName: () => path.basename(os.homedir()), - getAbsolutePath: () => os.homedir() - } - } - } - } - } -} diff --git a/packages/plugin-input-global-memory/src/index.ts b/packages/plugin-input-global-memory/src/index.ts deleted file mode 100644 index 963f6224..00000000 --- a/packages/plugin-input-global-memory/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - GlobalMemoryInputPlugin -} from './GlobalMemoryInputPlugin' diff --git a/packages/plugin-input-global-memory/tsconfig.eslint.json b/packages/plugin-input-global-memory/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-global-memory/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-global-memory/tsconfig.json b/packages/plugin-input-global-memory/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-global-memory/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-global-memory/tsconfig.lib.json b/packages/plugin-input-global-memory/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-global-memory/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-global-memory/tsconfig.test.json b/packages/plugin-input-global-memory/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-global-memory/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-global-memory/tsdown.config.ts b/packages/plugin-input-global-memory/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-global-memory/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-global-memory/vite.config.ts b/packages/plugin-input-global-memory/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-global-memory/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-global-memory/vitest.config.ts b/packages/plugin-input-global-memory/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-global-memory/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-jetbrains-config/eslint.config.ts b/packages/plugin-input-jetbrains-config/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-jetbrains-config/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-jetbrains-config/package.json b/packages/plugin-input-jetbrains-config/package.json deleted file mode 100644 index 31cd8175..00000000 --- a/packages/plugin-input-jetbrains-config/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-input-jetbrains-config", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-jetbrains-config for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-jetbrains-config/src/JetBrainsConfigInputPlugin.ts b/packages/plugin-input-jetbrains-config/src/JetBrainsConfigInputPlugin.ts deleted file mode 100644 index 2b3e25a0..00000000 --- a/packages/plugin-input-jetbrains-config/src/JetBrainsConfigInputPlugin.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '@truenine/plugin-shared' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {FilePathKind, IDEKind} from '@truenine/plugin-shared' - -function readIdeConfigFile( - type: T, - relativePath: string, - shadowProjectDir: string, - fs: typeof import('node:fs'), - path: typeof import('node:path') -): ProjectIDEConfigFile | undefined { - const absPath = path.join(shadowProjectDir, relativePath) - if (!(fs.existsSync(absPath) && fs.statSync(absPath).isFile())) return void 0 - - const content = fs.readFileSync(absPath, 'utf8') - return { - type, - content, - length: content.length, - filePathKind: FilePathKind.Absolute, - dir: { - pathKind: FilePathKind.Absolute, - path: absPath, - getDirectoryName: () => path.basename(absPath) - } - } -} - -export class JetBrainsConfigInputPlugin extends AbstractInputPlugin { - constructor() { - super('JetBrainsConfigInputPlugin') - } - - collect(ctx: InputPluginContext): Partial { - const {userConfigOptions, fs, path} = ctx - const {shadowProjectDir} = this.resolveBasePaths(userConfigOptions) - - const files = [ - '.idea/codeStyles/Project.xml', - '.idea/codeStyles/codeStyleConfig.xml', - '.idea/.gitignore' - ] - const jetbrainsConfigFiles: ProjectIDEConfigFile[] = [] - - for (const relativePath of files) { - const file = readIdeConfigFile(IDEKind.IntellijIDEA, relativePath, shadowProjectDir, fs, path) - if (file != null) jetbrainsConfigFiles.push(file) - } - - return {jetbrainsConfigFiles} - } -} diff --git a/packages/plugin-input-jetbrains-config/src/index.ts b/packages/plugin-input-jetbrains-config/src/index.ts deleted file mode 100644 index aab7350c..00000000 --- a/packages/plugin-input-jetbrains-config/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - JetBrainsConfigInputPlugin -} from './JetBrainsConfigInputPlugin' diff --git a/packages/plugin-input-jetbrains-config/tsconfig.eslint.json b/packages/plugin-input-jetbrains-config/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-jetbrains-config/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-jetbrains-config/tsconfig.json b/packages/plugin-input-jetbrains-config/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-jetbrains-config/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-jetbrains-config/tsconfig.lib.json b/packages/plugin-input-jetbrains-config/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-jetbrains-config/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-jetbrains-config/tsconfig.test.json b/packages/plugin-input-jetbrains-config/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-jetbrains-config/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-jetbrains-config/tsdown.config.ts b/packages/plugin-input-jetbrains-config/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-jetbrains-config/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-jetbrains-config/vite.config.ts b/packages/plugin-input-jetbrains-config/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-jetbrains-config/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-jetbrains-config/vitest.config.ts b/packages/plugin-input-jetbrains-config/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-jetbrains-config/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-md-cleanup-effect/eslint.config.ts b/packages/plugin-input-md-cleanup-effect/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-md-cleanup-effect/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-md-cleanup-effect/package.json b/packages/plugin-input-md-cleanup-effect/package.json deleted file mode 100644 index dc654995..00000000 --- a/packages/plugin-input-md-cleanup-effect/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-md-cleanup-effect", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-md-cleanup-effect for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "fast-glob": "catalog:" - } -} diff --git a/packages/plugin-input-md-cleanup-effect/src/MarkdownWhitespaceCleanupEffectInputPlugin.property.test.ts b/packages/plugin-input-md-cleanup-effect/src/MarkdownWhitespaceCleanupEffectInputPlugin.property.test.ts deleted file mode 100644 index b7cf2497..00000000 --- a/packages/plugin-input-md-cleanup-effect/src/MarkdownWhitespaceCleanupEffectInputPlugin.property.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import type {InputEffectContext} from '@truenine/plugin-input-shared' -import type {ILogger, PluginOptions} from '@truenine/plugin-shared' - -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import * as fc from 'fast-check' -import * as glob from 'fast-glob' -import {describe, expect, it} from 'vitest' -import {MarkdownWhitespaceCleanupEffectInputPlugin} from './MarkdownWhitespaceCleanupEffectInputPlugin' - -/** - * Feature: effect-input-plugins - * Property-based tests for MarkdownWhitespaceCleanupEffectInputPlugin - * - * Property 8: Trailing whitespace removal - * For any .md file processed by MarkdownWhitespaceCleanupEffectInputPlugin, - * no line in the output should end with space or tab characters. - * - * Property 9: Excessive blank line reduction - * For any .md file processed by MarkdownWhitespaceCleanupEffectInputPlugin, - * the output should contain at most 2 consecutive blank lines. - * - * Property 11: Line ending preservation - * For any .md file processed by MarkdownWhitespaceCleanupEffectInputPlugin, - * the line ending style (LF or CRLF) should be preserved in the output. - * - * Validates: Requirements 3.2, 3.3, 3.7 - */ - -function createMockLogger(): ILogger { // Test helpers - return { - trace: () => { }, - debug: () => { }, - info: () => { }, - warn: () => { }, - error: () => { }, - fatal: () => { }, - child: () => createMockLogger() - } as unknown as ILogger -} - -function createEffectContext(workspaceDir: string, shadowProjectDir: string, dryRun: boolean = false): InputEffectContext { - return { - logger: createMockLogger(), - fs, - path, - glob, - userConfigOptions: {} as PluginOptions, - workspaceDir, - shadowProjectDir, - dryRun - } -} // Generators - -const lineContentGen = fc.string({minLength: 0, maxLength: 100, unit: 'grapheme-ascii'}) // Generate a line of text (without line endings) - .filter(s => !s.includes('\n') && !s.includes('\r')) - -const trailingWhitespaceGen = fc.array( // Generate trailing whitespace (spaces and tabs) - fc.constantFrom(' ', '\t'), - {minLength: 0, maxLength: 10} -).map(chars => chars.join('')) - -const lineWithTrailingWhitespaceGen = fc.tuple(lineContentGen, trailingWhitespaceGen) // Generate a line with optional trailing whitespace - .map(([content, trailing]) => content + trailing) - -const markdownContentGen = fc.array(lineWithTrailingWhitespaceGen, {minLength: 1, maxLength: 20}) // Generate markdown content with various whitespace patterns - .chain(lines => - fc.array( // Randomly insert extra blank lines between content lines - fc.tuple( - fc.constant(null as string | null), - fc.integer({min: 0, max: 5}) // Number of blank lines to insert - ), - {minLength: lines.length, maxLength: lines.length} - ).map(blankCounts => { - const result: string[] = [] - for (let i = 0; i < lines.length; i++) { - const blankCount = blankCounts[i]?.[1] ?? 0 // Add blank lines before this line - for (let j = 0; j < blankCount; j++) result.push('') - result.push(lines[i]!) - } - return result - })) - -const lineEndingGen = fc.constantFrom('\n', '\r\n') // Generate line ending style - -const markdownWithLineEndingGen = fc.tuple(markdownContentGen, lineEndingGen) // Generate complete markdown content with specific line ending - .map(([lines, lineEnding]) => lines.join(lineEnding)) - -describe('markdownWhitespaceCleanupEffectInputPlugin Property Tests', () => { - describe('property 8: Trailing whitespace removal', () => { - it('should remove all trailing whitespace from every line', async () => { - const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() - - await fc.assert( - fc.asyncProperty( - markdownWithLineEndingGen, - async content => { - const cleaned = plugin.cleanMarkdownContent(content) // Process the content - - const lines = cleaned.split(/\r?\n/) // Split into lines (handle both LF and CRLF) - - for (const line of lines) expect(line).not.toMatch(/[ \t]$/) // Verify: No line should end with space or tab - } - ), - {numRuns: 100} - ) - }) - - it('should remove trailing whitespace in actual files', async () => { - await fc.assert( - fc.asyncProperty( - markdownWithLineEndingGen, - async content => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'whitespace-p8-')) // Create isolated temp directory for this property run - - try { - const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project with markdown file - const srcDir = path.join(shadowProjectDir, 'src') - - fs.mkdirSync(srcDir, {recursive: true}) - - const mdFilePath = path.join(srcDir, 'test.md') - fs.writeFileSync(mdFilePath, content, 'utf8') - - const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() // Execute plugin - const ctx = createEffectContext(tempDir, shadowProjectDir, false) - const effectMethod = (plugin as any).cleanupWhitespace.bind(plugin) - await effectMethod(ctx) - - const processedContent = fs.readFileSync(mdFilePath, 'utf8') // Read the processed file - const lines = processedContent.split(/\r?\n/) - - for (const line of lines) expect(line).not.toMatch(/[ \t]$/) // Verify: No line should end with space or tab - } - finally { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup - } - } - ), - {numRuns: 100} - ) - }, 120000) - }) - - describe('property 9: Excessive blank line reduction', () => { - it('should reduce consecutive blank lines to at most 2', async () => { - const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() - - await fc.assert( - fc.asyncProperty( - markdownWithLineEndingGen, - async content => { - const cleaned = plugin.cleanMarkdownContent(content) // Process the content - - const lines = cleaned.split(/\r?\n/) // Split into lines (handle both LF and CRLF) - - let maxConsecutiveBlank = 0 // Count consecutive blank lines - let currentConsecutiveBlank = 0 - - for (const line of lines) { - if (line === '') { - currentConsecutiveBlank++ - maxConsecutiveBlank = Math.max(maxConsecutiveBlank, currentConsecutiveBlank) - } else currentConsecutiveBlank = 0 - } - - expect(maxConsecutiveBlank).toBeLessThanOrEqual(2) // Verify: At most 2 consecutive blank lines - } - ), - {numRuns: 100} - ) - }) - - it('should reduce excessive blank lines in actual files', async () => { - await fc.assert( - fc.asyncProperty( - markdownWithLineEndingGen, - async content => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'whitespace-p9-')) // Create isolated temp directory for this property run - - try { - const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project with markdown file - const srcDir = path.join(shadowProjectDir, 'src') - - fs.mkdirSync(srcDir, {recursive: true}) - - const mdFilePath = path.join(srcDir, 'test.md') - fs.writeFileSync(mdFilePath, content, 'utf8') - - const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() // Execute plugin - const ctx = createEffectContext(tempDir, shadowProjectDir, false) - const effectMethod = (plugin as any).cleanupWhitespace.bind(plugin) - await effectMethod(ctx) - - const processedContent = fs.readFileSync(mdFilePath, 'utf8') // Read the processed file - const lines = processedContent.split(/\r?\n/) - - let maxConsecutiveBlank = 0 // Count consecutive blank lines - let currentConsecutiveBlank = 0 - - for (const line of lines) { - if (line === '') { - currentConsecutiveBlank++ - maxConsecutiveBlank = Math.max(maxConsecutiveBlank, currentConsecutiveBlank) - } else currentConsecutiveBlank = 0 - } - - expect(maxConsecutiveBlank).toBeLessThanOrEqual(2) // Verify: At most 2 consecutive blank lines - } - finally { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup - } - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 11: Line ending preservation', () => { - it('should preserve LF line endings', async () => { - const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() - - await fc.assert( - fc.asyncProperty( - markdownContentGen, - async lines => { - const content = lines.join('\n') // Create content with LF line endings - - const cleaned = plugin.cleanMarkdownContent(content) // Process the content - - expect(cleaned).not.toContain('\r\n') // Verify: Should not contain CRLF - - if (lines.length > 1) expect(cleaned).toContain('\n') // Verify: If multi-line, should contain LF - } - ), - {numRuns: 100} - ) - }) - - it('should preserve CRLF line endings', async () => { - const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() - - await fc.assert( - fc.asyncProperty( - markdownContentGen, - async lines => { - const content = lines.join('\r\n') // Create content with CRLF line endings - - const cleaned = plugin.cleanMarkdownContent(content) // Process the content - - if (lines.length <= 1) return // Verify: If multi-line, should use CRLF - - const crlfCount = (cleaned.match(/\r\n/g) ?? []).length - const lfOnlyCount = (cleaned.replaceAll('\r\n', '').match(/\n/g) ?? []).length - expect(lfOnlyCount).toBe(0) - expect(crlfCount).toBeGreaterThan(0) - } - ), - {numRuns: 100} - ) - }) - - it('should preserve line endings in actual files', async () => { - await fc.assert( - fc.asyncProperty( - markdownContentGen, - lineEndingGen, - async (lines, lineEnding) => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'whitespace-p11-')) // Create isolated temp directory for this property run - - try { - const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project with markdown file - const srcDir = path.join(shadowProjectDir, 'src') - - fs.mkdirSync(srcDir, {recursive: true}) - - const content = lines.join(lineEnding) // Create content with specific line ending - const mdFilePath = path.join(srcDir, 'test.md') - fs.writeFileSync(mdFilePath, content, 'utf8') - - const plugin = new MarkdownWhitespaceCleanupEffectInputPlugin() // Execute plugin - const ctx = createEffectContext(tempDir, shadowProjectDir, false) - const effectMethod = (plugin as any).cleanupWhitespace.bind(plugin) - await effectMethod(ctx) - - const processedContent = fs.readFileSync(mdFilePath, 'utf8') // Read the processed file - - if (lines.length > 1) { // Verify line ending preservation - if (lineEnding === '\r\n') { - const crlfCount = (processedContent.match(/\r\n/g) ?? []).length // Should use CRLF - const lfOnlyCount = (processedContent.replaceAll('\r\n', '').match(/\n/g) ?? []).length - expect(lfOnlyCount).toBe(0) - expect(crlfCount).toBeGreaterThan(0) - } else { - expect(processedContent).not.toContain('\r\n') // Should use LF (no CRLF) - expect(processedContent).toContain('\n') - } - } - } - finally { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup - } - } - ), - {numRuns: 100} - ) - }) - }) -}) diff --git a/packages/plugin-input-md-cleanup-effect/src/MarkdownWhitespaceCleanupEffectInputPlugin.ts b/packages/plugin-input-md-cleanup-effect/src/MarkdownWhitespaceCleanupEffectInputPlugin.ts deleted file mode 100644 index e2d40cd3..00000000 --- a/packages/plugin-input-md-cleanup-effect/src/MarkdownWhitespaceCleanupEffectInputPlugin.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { - CollectedInputContext, - InputEffectContext, - InputEffectResult, - InputPluginContext -} from '@truenine/plugin-shared' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' - -/** - * Result of the markdown whitespace cleanup effect. - */ -export interface WhitespaceCleanupEffectResult extends InputEffectResult { - readonly modifiedFiles: string[] - readonly skippedFiles: string[] -} - -export class MarkdownWhitespaceCleanupEffectInputPlugin extends AbstractInputPlugin { - constructor() { - super('MarkdownWhitespaceCleanupEffectInputPlugin') - this.registerEffect('markdown-whitespace-cleanup', this.cleanupWhitespace.bind(this), 30) - } - - private async cleanupWhitespace(ctx: InputEffectContext): Promise { - const {fs, path, shadowProjectDir, dryRun, logger} = ctx - - const modifiedFiles: string[] = [] - const skippedFiles: string[] = [] - const errors: {path: string, error: Error}[] = [] - - const dirsToScan = [ - path.join(shadowProjectDir, 'src'), - path.join(shadowProjectDir, 'app'), - path.join(shadowProjectDir, 'dist') - ] - - for (const dir of dirsToScan) { - if (!fs.existsSync(dir)) { - logger.debug({action: 'whitespace-cleanup', message: 'Directory does not exist, skipping', dir}) - continue - } - - this.processDirectory(ctx, dir, modifiedFiles, skippedFiles, errors, dryRun ?? false) - } - - const hasErrors = errors.length > 0 - if (hasErrors) logger.warn({action: 'whitespace-cleanup', errors: errors.map(e => ({path: e.path, error: e.error.message}))}) - - return { - success: !hasErrors, - description: dryRun - ? `Would modify ${modifiedFiles.length} files, skip ${skippedFiles.length} files` - : `Modified ${modifiedFiles.length} files, skipped ${skippedFiles.length} files`, - modifiedFiles, - skippedFiles, - ...hasErrors && {error: new Error(`${errors.length} errors occurred during cleanup`)} - } - } - - private processDirectory( - ctx: InputEffectContext, - dir: string, - modifiedFiles: string[], - skippedFiles: string[], - errors: {path: string, error: Error}[], - dryRun: boolean - ): void { - const {fs, path, logger} = ctx - - let entries: import('node:fs').Dirent[] - try { - entries = fs.readdirSync(dir, {withFileTypes: true}) - } - catch (error) { - errors.push({path: dir, error: error as Error}) - logger.warn({action: 'whitespace-cleanup', message: 'Failed to read directory', path: dir, error: (error as Error).message}) - return - } - - for (const entry of entries) { - const entryPath = path.join(dir, entry.name) - - if (entry.isDirectory()) this.processDirectory(ctx, entryPath, modifiedFiles, skippedFiles, errors, dryRun) - else if (entry.isFile() && entry.name.endsWith('.md')) this.processMarkdownFile(ctx, entryPath, modifiedFiles, skippedFiles, errors, dryRun) - } - } - - private processMarkdownFile( - ctx: InputEffectContext, - filePath: string, - modifiedFiles: string[], - skippedFiles: string[], - errors: {path: string, error: Error}[], - dryRun: boolean - ): void { - const {fs, logger} = ctx - - try { - const originalContent = fs.readFileSync(filePath, 'utf8') - const cleanedContent = this.cleanMarkdownContent(originalContent) - - if (originalContent === cleanedContent) { - skippedFiles.push(filePath) - logger.debug({action: 'whitespace-cleanup', skipped: filePath, reason: 'no changes needed'}) - return - } - - if (dryRun) { - logger.debug({action: 'whitespace-cleanup', dryRun: true, wouldModify: filePath}) - modifiedFiles.push(filePath) - } else { - fs.writeFileSync(filePath, cleanedContent, 'utf8') - modifiedFiles.push(filePath) - logger.debug({action: 'whitespace-cleanup', modified: filePath}) - } - } - catch (error) { - errors.push({path: filePath, error: error as Error}) - logger.warn({action: 'whitespace-cleanup', message: 'Failed to process file', path: filePath, error: (error as Error).message}) - } - } - - cleanMarkdownContent(content: string): string { - const lineEnding = this.detectLineEnding(content) - - const lines = content.split(/\r?\n/) - - const trimmedLines = lines.map(line => line.replace(/[ \t]+$/, '')) - - const result: string[] = [] - let consecutiveBlankCount = 0 - - for (const line of trimmedLines) { - if (line === '') { - consecutiveBlankCount++ - if (consecutiveBlankCount <= 2) result.push(line) - } else { - consecutiveBlankCount = 0 - result.push(line) - } - } - - return result.join(lineEnding) - } - - detectLineEnding(content: string): '\r\n' | '\n' { - if (content.includes('\r\n')) return '\r\n' - return '\n' - } - - collect(_ctx: InputPluginContext): Partial { - return {} - } -} diff --git a/packages/plugin-input-md-cleanup-effect/src/index.ts b/packages/plugin-input-md-cleanup-effect/src/index.ts deleted file mode 100644 index 1ec6b1bc..00000000 --- a/packages/plugin-input-md-cleanup-effect/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - MarkdownWhitespaceCleanupEffectInputPlugin -} from './MarkdownWhitespaceCleanupEffectInputPlugin' -export type { - WhitespaceCleanupEffectResult -} from './MarkdownWhitespaceCleanupEffectInputPlugin' diff --git a/packages/plugin-input-md-cleanup-effect/tsconfig.eslint.json b/packages/plugin-input-md-cleanup-effect/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-md-cleanup-effect/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-md-cleanup-effect/tsconfig.json b/packages/plugin-input-md-cleanup-effect/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-md-cleanup-effect/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-md-cleanup-effect/tsconfig.lib.json b/packages/plugin-input-md-cleanup-effect/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-md-cleanup-effect/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-md-cleanup-effect/tsconfig.test.json b/packages/plugin-input-md-cleanup-effect/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-md-cleanup-effect/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-md-cleanup-effect/tsdown.config.ts b/packages/plugin-input-md-cleanup-effect/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-md-cleanup-effect/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-md-cleanup-effect/vite.config.ts b/packages/plugin-input-md-cleanup-effect/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-md-cleanup-effect/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-md-cleanup-effect/vitest.config.ts b/packages/plugin-input-md-cleanup-effect/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-md-cleanup-effect/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-orphan-cleanup-effect/eslint.config.ts b/packages/plugin-input-orphan-cleanup-effect/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-orphan-cleanup-effect/package.json b/packages/plugin-input-orphan-cleanup-effect/package.json deleted file mode 100644 index 40af636c..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-orphan-cleanup-effect", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-orphan-cleanup-effect for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "fast-glob": "catalog:" - } -} diff --git a/packages/plugin-input-orphan-cleanup-effect/src/OrphanFileCleanupEffectInputPlugin.property.test.ts b/packages/plugin-input-orphan-cleanup-effect/src/OrphanFileCleanupEffectInputPlugin.property.test.ts deleted file mode 100644 index 9b5551d2..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/src/OrphanFileCleanupEffectInputPlugin.property.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type {InputEffectContext} from '@truenine/plugin-input-shared' -import type {ILogger, PluginOptions} from '@truenine/plugin-shared' - -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import * as fc from 'fast-check' -import * as glob from 'fast-glob' -import {describe, expect, it} from 'vitest' -import {OrphanFileCleanupEffectInputPlugin} from './OrphanFileCleanupEffectInputPlugin' - -/** - * Feature: effect-input-plugins - * Property-based tests for OrphanFileCleanupEffectInputPlugin - * - * Property 5: Orphan .mdx file deletion - * For any .mdx file in dist/skills/, dist/commands/, dist/agents/, or dist/app/, - * if no corresponding source file exists according to the mapping rules, - * the file should be deleted after OrphanFileCleanupEffectInputPlugin executes. - * - * Property 7: Empty directory cleanup - * For any directory in dist/ that becomes empty after orphan file deletion, - * the directory should be removed by OrphanFileCleanupEffectInputPlugin. - * - * Validates: Requirements 2.2, 2.3, 2.4, 2.5, 2.7 - */ - -function createMockLogger(): ILogger { // Test helpers - return { - trace: () => { }, - debug: () => { }, - info: () => { }, - warn: () => { }, - error: () => { }, - fatal: () => { }, - child: () => createMockLogger() - } as unknown as ILogger -} - -function createEffectContext(workspaceDir: string, shadowProjectDir: string, dryRun: boolean = false): InputEffectContext { - return { - logger: createMockLogger(), - fs, - path, - glob, - userConfigOptions: {} as PluginOptions, - workspaceDir, - shadowProjectDir, - dryRun - } -} - -const validNameGen = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generators - .filter(s => /^[\w-]+$/.test(s)) - .map(s => s.toLowerCase()) - -const dirTypeGen = fc.constantFrom('skills', 'commands', 'agents', 'app') - -interface DistFile { // Generate a dist file structure with orphan and valid files - name: string - dirType: 'skills' | 'commands' | 'agents' | 'app' - hasSource: boolean -} - -const distFileGen: fc.Arbitrary = fc.record({name: validNameGen, dirType: dirTypeGen, hasSource: fc.boolean()}) - -describe('orphanFileCleanupEffectInputPlugin Property Tests', () => { - describe('property 5: Orphan .mdx file deletion', () => { - it('should delete orphan .mdx files and keep files with valid sources', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(distFileGen, {minLength: 1, maxLength: 10}) - .map(files => { - const seen = new Set() // Deduplicate by (name, dirType) to avoid conflicts - return files.filter(f => { - const key = `${f.dirType}:${f.name}` - if (seen.has(key)) return false - seen.add(key) - return true - }) - }) - .filter(files => files.length > 0), - async distFiles => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orphan-cleanup-p5-')) // Create isolated temp directory for this property run - - try { - const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project structure - const distDir = path.join(shadowProjectDir, 'dist') - const srcDir = path.join(shadowProjectDir, 'src') - const appDir = path.join(shadowProjectDir, 'app') - - fs.mkdirSync(distDir, {recursive: true}) // Create directories - fs.mkdirSync(srcDir, {recursive: true}) - fs.mkdirSync(appDir, {recursive: true}) - - const expectedDeleted: string[] = [] // Track expected outcomes - const expectedKept: string[] = [] - - for (const file of distFiles) { // Create dist files and optionally their sources - const distTypePath = path.join(distDir, file.dirType) - fs.mkdirSync(distTypePath, {recursive: true}) - - const distFilePath = path.join(distTypePath, `${file.name}.mdx`) - fs.writeFileSync(distFilePath, `# ${file.name}`, 'utf8') - - if (file.hasSource) { - createSourceFile(shadowProjectDir, file.dirType, file.name) // Create corresponding source file - expectedKept.push(distFilePath) - } else expectedDeleted.push(distFilePath) - } - - const plugin = new OrphanFileCleanupEffectInputPlugin() // Execute plugin - const ctx = createEffectContext(tempDir, shadowProjectDir, false) - const effectMethod = (plugin as any).cleanupOrphanFiles.bind(plugin) - const result = await effectMethod(ctx) - - for (const filePath of expectedDeleted) { // Verify: Orphan files should be deleted - expect(fs.existsSync(filePath)).toBe(false) - expect(result.deletedFiles).toContain(filePath) - } - - for (const filePath of expectedKept) { // Verify: Files with sources should be kept - expect(fs.existsSync(filePath)).toBe(true) - expect(result.deletedFiles).not.toContain(filePath) - } - } - finally { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup - } - } - ), - {numRuns: 100} - ) - }, 120000) - }) - - describe('property 7: Empty directory cleanup', () => { - it('should remove directories that become empty after orphan deletion', async () => { - await fc.assert( - fc.asyncProperty( - validNameGen, - dirTypeGen, - async (name, dirType) => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orphan-cleanup-p7-')) // Create isolated temp directory for this property run - - try { - const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project with orphan file in subdirectory - const distDir = path.join(shadowProjectDir, 'dist') - const distTypeDir = path.join(distDir, dirType) - const subDir = path.join(distTypeDir, 'subdir') - - fs.mkdirSync(subDir, {recursive: true}) - - const orphanFilePath = path.join(subDir, `${name}.mdx`) // Create orphan file in subdirectory (no source) - fs.writeFileSync(orphanFilePath, `# ${name}`, 'utf8') - - expect(fs.existsSync(subDir)).toBe(true) // Verify setup: subdirectory exists with file - expect(fs.existsSync(orphanFilePath)).toBe(true) - - const plugin = new OrphanFileCleanupEffectInputPlugin() // Execute plugin - const ctx = createEffectContext(tempDir, shadowProjectDir, false) - const effectMethod = (plugin as any).cleanupOrphanFiles.bind(plugin) - const result = await effectMethod(ctx) - - expect(fs.existsSync(orphanFilePath)).toBe(false) // Verify: Orphan file should be deleted - expect(result.deletedFiles).toContain(orphanFilePath) - - expect(fs.existsSync(subDir)).toBe(false) // Verify: Empty subdirectory should be removed - expect(result.deletedDirs).toContain(subDir) - } - finally { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup - } - } - ), - {numRuns: 100} - ) - }) - - it('should not remove directories that still contain files', async () => { - await fc.assert( - fc.asyncProperty( - validNameGen, - validNameGen, - async (orphanName, validName) => { - if (orphanName === validName) return // Ensure different names - - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orphan-cleanup-p7b-')) // Create isolated temp directory for this property run - - try { - const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create shadow project with both orphan and valid files - const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') - const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') - - fs.mkdirSync(distSkillsDir, {recursive: true}) - fs.mkdirSync(srcSkillsDir, {recursive: true}) - - const orphanFilePath = path.join(distSkillsDir, `${orphanName}.mdx`) // Create orphan file (no source) - fs.writeFileSync(orphanFilePath, `# ${orphanName}`, 'utf8') - - const validFilePath = path.join(distSkillsDir, `${validName}.mdx`) // Create valid file with source - fs.writeFileSync(validFilePath, `# ${validName}`, 'utf8') - - const srcSkillDir = path.join(srcSkillsDir, validName) // Create source for valid file - fs.mkdirSync(srcSkillDir, {recursive: true}) - fs.writeFileSync(path.join(srcSkillDir, 'SKILL.cn.mdx'), `# ${validName}`, 'utf8') - - const plugin = new OrphanFileCleanupEffectInputPlugin() // Execute plugin - const ctx = createEffectContext(tempDir, shadowProjectDir, false) - const effectMethod = (plugin as any).cleanupOrphanFiles.bind(plugin) - await effectMethod(ctx) - - expect(fs.existsSync(orphanFilePath)).toBe(false) // Verify: Orphan file deleted, valid file kept - expect(fs.existsSync(validFilePath)).toBe(true) - - expect(fs.existsSync(distSkillsDir)).toBe(true) // Verify: Directory should NOT be removed (still has valid file) - } - finally { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup - } - } - ), - {numRuns: 100} - ) - }) - }) -}) - -/** - * Helper function to create source file based on directory type and mapping rules. - */ -function createSourceFile( - shadowProjectDir: string, - dirType: 'skills' | 'commands' | 'agents' | 'app', - name: string -): void { - switch (dirType) { - case 'skills': { - const skillDir = path.join(shadowProjectDir, 'src', 'skills', name) // src/skills/{name}/SKILL.cn.mdx - fs.mkdirSync(skillDir, {recursive: true}) - fs.writeFileSync(path.join(skillDir, 'SKILL.cn.mdx'), `# ${name}`, 'utf8') - break - } - case 'commands': { - const commandsDir = path.join(shadowProjectDir, 'src', 'commands') // src/commands/{name}.cn.mdx - fs.mkdirSync(commandsDir, {recursive: true}) - fs.writeFileSync(path.join(commandsDir, `${name}.cn.mdx`), `# ${name}`, 'utf8') - break - } - case 'agents': { - const agentsDir = path.join(shadowProjectDir, 'src', 'agents') // src/agents/{name}.cn.mdx - fs.mkdirSync(agentsDir, {recursive: true}) - fs.writeFileSync(path.join(agentsDir, `${name}.cn.mdx`), `# ${name}`, 'utf8') - break - } - case 'app': { - const appDir = path.join(shadowProjectDir, 'app') // app/{name}.cn.mdx - fs.mkdirSync(appDir, {recursive: true}) - fs.writeFileSync(path.join(appDir, `${name}.cn.mdx`), `# ${name}`, 'utf8') - break - } - } -} diff --git a/packages/plugin-input-orphan-cleanup-effect/src/OrphanFileCleanupEffectInputPlugin.ts b/packages/plugin-input-orphan-cleanup-effect/src/OrphanFileCleanupEffectInputPlugin.ts deleted file mode 100644 index b8a8f8c9..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/src/OrphanFileCleanupEffectInputPlugin.ts +++ /dev/null @@ -1,214 +0,0 @@ -import type {CollectedInputContext, InputEffectContext, InputEffectResult, InputPluginContext} from '@truenine/plugin-shared' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' - -/** - * Result of the orphan file cleanup effect. - */ -export interface OrphanCleanupEffectResult extends InputEffectResult { - readonly deletedFiles: string[] - readonly deletedDirs: string[] -} - -export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { - constructor() { - super('OrphanFileCleanupEffectInputPlugin') - this.registerEffect('orphan-file-cleanup', this.cleanupOrphanFiles.bind(this), 20) - } - - private async cleanupOrphanFiles(ctx: InputEffectContext): Promise { - const {fs, path, shadowProjectDir, dryRun, logger} = ctx - - const distDir = path.join(shadowProjectDir, 'dist') - - const deletedFiles: string[] = [] - const deletedDirs: string[] = [] - const errors: {path: string, error: Error}[] = [] - - if (!fs.existsSync(distDir)) { - logger.debug({action: 'orphan-cleanup', message: 'dist/ directory does not exist, skipping', distDir}) - return { - success: true, - description: 'dist/ directory does not exist, nothing to clean', - deletedFiles, - deletedDirs - } - } - - const distSubDirs = ['skills', 'commands', 'agents', 'app'] - - for (const subDir of distSubDirs) { - const distSubDirPath = path.join(distDir, subDir) - if (fs.existsSync(distSubDirPath)) this.cleanupDirectory(ctx, distSubDirPath, subDir, deletedFiles, deletedDirs, errors, dryRun ?? false) - } - - const hasErrors = errors.length > 0 - if (hasErrors) logger.warn({action: 'orphan-cleanup', errors: errors.map(e => ({path: e.path, error: e.error.message}))}) - - return { - success: !hasErrors, - description: dryRun - ? `Would delete ${deletedFiles.length} files and ${deletedDirs.length} directories` - : `Deleted ${deletedFiles.length} files and ${deletedDirs.length} directories`, - deletedFiles, - deletedDirs, - ...hasErrors && {error: new Error(`${errors.length} errors occurred during cleanup`)} - } - } - - private cleanupDirectory( - ctx: InputEffectContext, - distDirPath: string, - dirType: string, - deletedFiles: string[], - deletedDirs: string[], - errors: {path: string, error: Error}[], - dryRun: boolean - ): void { - const {fs, path, shadowProjectDir, logger} = ctx - - let entries: import('node:fs').Dirent[] - try { - entries = fs.readdirSync(distDirPath, {withFileTypes: true}) - } - catch (error) { - errors.push({path: distDirPath, error: error as Error}) - logger.warn({action: 'orphan-cleanup', message: 'Failed to read directory', path: distDirPath, error: (error as Error).message}) - return - } - - for (const entry of entries) { - const entryPath = path.join(distDirPath, entry.name) - - if (entry.isDirectory()) { - this.cleanupDirectory(ctx, entryPath, dirType, deletedFiles, deletedDirs, errors, dryRun) - - this.removeEmptyDirectory(ctx, entryPath, deletedDirs, errors, dryRun) - } else if (entry.isFile()) { - const isOrphan = this.isOrphanFile(ctx, entryPath, dirType, shadowProjectDir) - - if (isOrphan) { - if (dryRun) { - logger.debug({action: 'orphan-cleanup', dryRun: true, wouldDelete: entryPath}) - deletedFiles.push(entryPath) - } else { - try { - fs.unlinkSync(entryPath) - deletedFiles.push(entryPath) - logger.debug({action: 'orphan-cleanup', deleted: entryPath}) - } - catch (error) { - errors.push({path: entryPath, error: error as Error}) - logger.warn({action: 'orphan-cleanup', message: 'Failed to delete file', path: entryPath, error: (error as Error).message}) - } - } - } - } - } - } - - private isOrphanFile( - ctx: InputEffectContext, - distFilePath: string, - dirType: string, - shadowProjectDir: string - ): boolean { - const {fs, path} = ctx - - const fileName = path.basename(distFilePath) - const isMdxFile = fileName.endsWith('.mdx') - - const distTypeDir = path.join(shadowProjectDir, 'dist', dirType) - const relativeFromType = path.relative(distTypeDir, distFilePath) - const relativeDir = path.dirname(relativeFromType) - const baseName = fileName.replace(/\.mdx$/, '') - - if (isMdxFile) { - const possibleSrcPaths = this.getPossibleSourcePaths(path, shadowProjectDir, dirType, baseName, relativeDir) - - return !possibleSrcPaths.some(srcPath => fs.existsSync(srcPath)) - } - const possibleSrcPaths: string[] = [] - - if (dirType === 'app') possibleSrcPaths.push(path.join(shadowProjectDir, 'app', relativeFromType)) - else possibleSrcPaths.push(path.join(shadowProjectDir, 'src', dirType, relativeFromType)) - - return !possibleSrcPaths.some(srcPath => fs.existsSync(srcPath)) - } - - private getPossibleSourcePaths( - nodePath: typeof import('node:path'), - shadowProjectDir: string, - dirType: string, - baseName: string, - relativeDir: string - ): string[] { - switch (dirType) { - case 'skills': - return relativeDir === '.' - ? [ - nodePath.join(shadowProjectDir, 'src', 'skills', baseName, 'SKILL.cn.mdx'), - nodePath.join(shadowProjectDir, 'src', 'skills', `${baseName}.cn.mdx`) - ] - : [ - nodePath.join(shadowProjectDir, 'src', 'skills', relativeDir, `${baseName}.cn.mdx`) - ] - case 'commands': - return relativeDir === '.' - ? [ - nodePath.join(shadowProjectDir, 'src', 'commands', `${baseName}.cn.mdx`) - ] - : [ - nodePath.join(shadowProjectDir, 'src', 'commands', relativeDir, `${baseName}.cn.mdx`) - ] - case 'agents': - return relativeDir === '.' - ? [ - nodePath.join(shadowProjectDir, 'src', 'agents', `${baseName}.cn.mdx`) - ] - : [ - nodePath.join(shadowProjectDir, 'src', 'agents', relativeDir, `${baseName}.cn.mdx`) - ] - case 'app': - return relativeDir === '.' - ? [ - nodePath.join(shadowProjectDir, 'app', `${baseName}.cn.mdx`) - ] - : [ - nodePath.join(shadowProjectDir, 'app', relativeDir, `${baseName}.cn.mdx`) - ] - default: return [] - } - } - - private removeEmptyDirectory( - ctx: InputEffectContext, - dirPath: string, - deletedDirs: string[], - errors: {path: string, error: Error}[], - dryRun: boolean - ): void { - const {fs, logger} = ctx - - try { - const entries = fs.readdirSync(dirPath) - if (entries.length === 0) { - if (dryRun) { - logger.debug({action: 'orphan-cleanup', dryRun: true, wouldDeleteDir: dirPath}) - deletedDirs.push(dirPath) - } else { - fs.rmdirSync(dirPath) - deletedDirs.push(dirPath) - logger.debug({action: 'orphan-cleanup', deletedDir: dirPath}) - } - } - } - catch (error) { - errors.push({path: dirPath, error: error as Error}) - logger.warn({action: 'orphan-cleanup', message: 'Failed to check/remove directory', path: dirPath, error: (error as Error).message}) - } - } - - collect(_ctx: InputPluginContext): Partial { - return {} - } -} diff --git a/packages/plugin-input-orphan-cleanup-effect/src/index.ts b/packages/plugin-input-orphan-cleanup-effect/src/index.ts deleted file mode 100644 index 52362bdb..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - OrphanFileCleanupEffectInputPlugin -} from './OrphanFileCleanupEffectInputPlugin' -export type { - OrphanCleanupEffectResult -} from './OrphanFileCleanupEffectInputPlugin' diff --git a/packages/plugin-input-orphan-cleanup-effect/tsconfig.eslint.json b/packages/plugin-input-orphan-cleanup-effect/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-orphan-cleanup-effect/tsconfig.json b/packages/plugin-input-orphan-cleanup-effect/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-orphan-cleanup-effect/tsconfig.lib.json b/packages/plugin-input-orphan-cleanup-effect/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-orphan-cleanup-effect/tsconfig.test.json b/packages/plugin-input-orphan-cleanup-effect/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-orphan-cleanup-effect/tsdown.config.ts b/packages/plugin-input-orphan-cleanup-effect/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-orphan-cleanup-effect/vite.config.ts b/packages/plugin-input-orphan-cleanup-effect/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-orphan-cleanup-effect/vitest.config.ts b/packages/plugin-input-orphan-cleanup-effect/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-orphan-cleanup-effect/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-project-prompt/eslint.config.ts b/packages/plugin-input-project-prompt/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-project-prompt/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-project-prompt/package.json b/packages/plugin-input-project-prompt/package.json deleted file mode 100644 index 9bc52d97..00000000 --- a/packages/plugin-input-project-prompt/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-project-prompt", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-project-prompt for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-project-prompt/src/ProjectPromptInputPlugin.test.ts b/packages/plugin-input-project-prompt/src/ProjectPromptInputPlugin.test.ts deleted file mode 100644 index 281125ab..00000000 --- a/packages/plugin-input-project-prompt/src/ProjectPromptInputPlugin.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import type {MdxGlobalScope} from '@truenine/md-compiler/globals' -import type {ILogger, InputPluginContext, PluginOptions, Workspace} from '@truenine/plugin-shared' -import * as path from 'node:path' -import {FilePathKind} from '@truenine/plugin-shared' -import {describe, expect, it, vi} from 'vitest' -import {ProjectPromptInputPlugin} from './ProjectPromptInputPlugin' - -const WORKSPACE_DIR = '/workspace' -const SHADOW_PROJECT_NAME = 'shadow' -const SHADOW_PROJECT_DIR = path.join(WORKSPACE_DIR, SHADOW_PROJECT_NAME) -const SHADOW_PROJECTS_DIR = path.join(SHADOW_PROJECT_DIR, 'dist/app') -const PROJECT_NAME = 'test-project' -const SHADOW_PROJECT_PATH = path.join(SHADOW_PROJECTS_DIR, PROJECT_NAME) -const TARGET_PROJECT_PATH = path.join(WORKSPACE_DIR, PROJECT_NAME) -const PROJECT_MEMORY_FILE = 'agt.mdx' -const SKIP_DIR_NODE_MODULES = 'node_modules' -const SKIP_DIR_GIT = '.git' -const MOCK_MDX_CONTENT = '# Test' - -function createMockLogger(): ILogger { - return { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - trace: vi.fn(), - fatal: vi.fn() - } -} - -function createMockOptions(): Required { - return { - workspaceDir: WORKSPACE_DIR, - shadowSourceProject: { - name: 'shadow', - skill: {src: 'src/skills', dist: 'dist/skills'}, - fastCommand: {src: 'src/commands', dist: 'dist/commands'}, - subAgent: {src: 'src/agents', dist: 'dist/agents'}, - rule: {src: 'src/rules', dist: 'dist/rules'}, - globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, - workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, - project: {src: 'app', dist: 'dist/app'} - }, - fastCommandSeriesOptions: {}, - plugins: [], - logLevel: 'info' - } -} - -function createMockWorkspace(): Workspace { - return { - directory: {pathKind: FilePathKind.Root, path: WORKSPACE_DIR, getDirectoryName: () => 'workspace'}, - projects: [{ - name: PROJECT_NAME, - dirFromWorkspacePath: { - pathKind: FilePathKind.Relative, - path: PROJECT_NAME, - basePath: WORKSPACE_DIR, - getDirectoryName: () => PROJECT_NAME, - getAbsolutePath: () => TARGET_PROJECT_PATH - } - }] - } -} - -function createMockGlobalScope(): MdxGlobalScope { - return { - profile: {name: 'test', username: 'test', gender: 'male', birthday: '2000-01-01'}, - tool: {name: 'test'}, - env: {}, - os: {platform: 'linux', arch: 'x64', homedir: '/home/test'}, - Md: vi.fn() as unknown as MdxGlobalScope['Md'] - } -} - -interface MockDirEntry {name: string, isDirectory: () => boolean, isFile: () => boolean} -const dirEntry = (name: string): MockDirEntry => ({name, isDirectory: () => true, isFile: () => false}) - -function createCtx(workspace: Workspace, mockFs: unknown): InputPluginContext { - return { - logger: createMockLogger(), - fs: mockFs as typeof import('node:fs'), - path, - glob: vi.fn() as unknown as typeof import('fast-glob'), - userConfigOptions: createMockOptions(), - dependencyContext: {workspace}, - globalScope: createMockGlobalScope() - } -} - -describe('projectPromptInputPlugin', () => { - describe('scanDirectoryRecursive - directory skip behavior', () => { - it('should skip node_modules directory', async () => { - const workspace = createMockWorkspace() - const mockFs = { - existsSync: vi.fn().mockImplementation((p: string) => { - if (p === SHADOW_PROJECTS_DIR || p === SHADOW_PROJECT_PATH) return true - if (p.includes(SKIP_DIR_NODE_MODULES)) return false - return p.endsWith(PROJECT_MEMORY_FILE) - }), - statSync: vi.fn().mockReturnValue({isDirectory: () => true, isFile: () => true}), - readdirSync: vi.fn().mockImplementation((dir: string) => { - if (path.normalize(dir) === path.normalize(SHADOW_PROJECT_PATH)) return [dirEntry(SKIP_DIR_NODE_MODULES), dirEntry('src')] - if (path.normalize(dir) === path.normalize(path.join(SHADOW_PROJECT_PATH, 'src'))) return [] - if (dir.includes(SKIP_DIR_NODE_MODULES)) throw new Error(`Should not scan ${SKIP_DIR_NODE_MODULES}`) - return [] - }), - readFileSync: vi.fn().mockReturnValue(MOCK_MDX_CONTENT) - } - const result = await new ProjectPromptInputPlugin().collect(createCtx(workspace, mockFs)) - const project = result.workspace?.projects.find(p => p.name === PROJECT_NAME) - const matched = project?.childMemoryPrompts?.filter(c => c.dir.path.includes(SKIP_DIR_NODE_MODULES)) - expect(matched ?? []).toHaveLength(0) - }) - - it('should skip .git directory', async () => { - const workspace = createMockWorkspace() - const mockFs = { - existsSync: vi.fn().mockImplementation((p: string) => { - if (p === SHADOW_PROJECTS_DIR || p === SHADOW_PROJECT_PATH) return true - if (p.includes(SKIP_DIR_GIT)) return false - return p.endsWith(PROJECT_MEMORY_FILE) - }), - statSync: vi.fn().mockReturnValue({isDirectory: () => true, isFile: () => true}), - readdirSync: vi.fn().mockImplementation((dir: string) => { - if (path.normalize(dir) === path.normalize(SHADOW_PROJECT_PATH)) return [dirEntry(SKIP_DIR_GIT), dirEntry('src')] - if (path.normalize(dir) === path.normalize(path.join(SHADOW_PROJECT_PATH, 'src'))) return [] - if (dir.includes(SKIP_DIR_GIT)) throw new Error(`Should not scan ${SKIP_DIR_GIT}`) - return [] - }), - readFileSync: vi.fn().mockReturnValue(MOCK_MDX_CONTENT) - } - const result = await new ProjectPromptInputPlugin().collect(createCtx(workspace, mockFs)) - const project = result.workspace?.projects.find(p => p.name === PROJECT_NAME) - const matched = project?.childMemoryPrompts?.filter(c => c.dir.path.includes(SKIP_DIR_GIT)) - expect(matched ?? []).toHaveLength(0) - }) - - it('should allow .vscode directory with agt.mdx', async () => { - const workspace = createMockWorkspace() - const vscodeDir = '.vscode' - const vscodePath = path.join(SHADOW_PROJECT_PATH, vscodeDir) - const mockFs = { - existsSync: vi.fn().mockImplementation((p: string) => { - if (p === SHADOW_PROJECTS_DIR || p === SHADOW_PROJECT_PATH) return true - return path.normalize(p) === path.normalize(path.join(vscodePath, PROJECT_MEMORY_FILE)) - }), - statSync: vi.fn().mockReturnValue({isDirectory: () => true, isFile: () => true}), - readdirSync: vi.fn().mockImplementation((dir: string) => { - if (path.normalize(dir) === path.normalize(SHADOW_PROJECT_PATH)) return [dirEntry(vscodeDir)] - return [] - }), - readFileSync: vi.fn().mockReturnValue(MOCK_MDX_CONTENT) - } - const result = await new ProjectPromptInputPlugin().collect(createCtx(workspace, mockFs)) - const project = result.workspace?.projects.find(p => p.name === PROJECT_NAME) - expect(project?.childMemoryPrompts).toHaveLength(1) - expect(project?.childMemoryPrompts?.[0]?.dir.path).toBe(vscodeDir) - }) - - it('should allow .idea directory with agt.mdx', async () => { - const workspace = createMockWorkspace() - const ideaDir = '.idea' - const ideaPath = path.join(SHADOW_PROJECT_PATH, ideaDir) - const mockFs = { - existsSync: vi.fn().mockImplementation((p: string) => { - if (p === SHADOW_PROJECTS_DIR || p === SHADOW_PROJECT_PATH) return true - return path.normalize(p) === path.normalize(path.join(ideaPath, PROJECT_MEMORY_FILE)) - }), - statSync: vi.fn().mockReturnValue({isDirectory: () => true, isFile: () => true}), - readdirSync: vi.fn().mockImplementation((dir: string) => { - if (path.normalize(dir) === path.normalize(SHADOW_PROJECT_PATH)) return [dirEntry(ideaDir)] - return [] - }), - readFileSync: vi.fn().mockReturnValue(MOCK_MDX_CONTENT) - } - const result = await new ProjectPromptInputPlugin().collect(createCtx(workspace, mockFs)) - const project = result.workspace?.projects.find(p => p.name === PROJECT_NAME) - expect(project?.childMemoryPrompts).toHaveLength(1) - expect(project?.childMemoryPrompts?.[0]?.dir.path).toBe(ideaDir) - }) - - it('should scan mixed directories, skipping only node_modules and .git', async () => { - const workspace = createMockWorkspace() - const allowedDirs = ['.vscode', '.idea', 'src', 'app'] - const skippedDirs = [SKIP_DIR_NODE_MODULES, SKIP_DIR_GIT] - const allDirs = [...allowedDirs, ...skippedDirs] - const mockFs = { - existsSync: vi.fn().mockImplementation((p: string) => { - if (p === SHADOW_PROJECTS_DIR || p === SHADOW_PROJECT_PATH) return true - for (const dir of allowedDirs) { - if (path.normalize(p) === path.normalize(path.join(SHADOW_PROJECT_PATH, dir, PROJECT_MEMORY_FILE))) return true - } - return false - }), - statSync: vi.fn().mockReturnValue({isDirectory: () => true, isFile: () => true}), - readdirSync: vi.fn().mockImplementation((dir: string) => { - if (path.normalize(dir) === path.normalize(SHADOW_PROJECT_PATH)) return allDirs.map(d => dirEntry(d)) - for (const d of skippedDirs) { - if (dir.includes(d)) throw new Error(`Should not scan skipped directory: ${d}`) - } - return [] - }), - readFileSync: vi.fn().mockReturnValue(MOCK_MDX_CONTENT) - } - const result = await new ProjectPromptInputPlugin().collect(createCtx(workspace, mockFs)) - const project = result.workspace?.projects.find(p => p.name === PROJECT_NAME) - expect(project?.childMemoryPrompts).toHaveLength(allowedDirs.length) - const collectedPaths = project?.childMemoryPrompts?.map(c => c.dir.path) ?? [] - for (const dir of allowedDirs) expect(collectedPaths).toContain(dir) - for (const dir of skippedDirs) expect(collectedPaths).not.toContain(dir) - }) - }) -}) diff --git a/packages/plugin-input-project-prompt/src/ProjectPromptInputPlugin.ts b/packages/plugin-input-project-prompt/src/ProjectPromptInputPlugin.ts deleted file mode 100644 index 5ac29d81..00000000 --- a/packages/plugin-input-project-prompt/src/ProjectPromptInputPlugin.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { - CollectedInputContext, - InputPluginContext, - ProjectChildrenMemoryPrompt, - ProjectRootMemoryPrompt, - YAMLFrontMatter -} from '@truenine/plugin-shared' - -import process from 'node:process' - -import {mdxToMd} from '@truenine/md-compiler' -import {ScopeError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind, - PromptKind -} from '@truenine/plugin-shared' - -/** - * Project memory prompt file name - */ -const PROJECT_MEMORY_FILE = 'agt.mdx' - -/** - * Directories to skip during recursive scanning - */ -const SCAN_SKIP_DIRECTORIES: readonly string[] = ['node_modules', '.git'] as const - -export class ProjectPromptInputPlugin extends AbstractInputPlugin { - constructor() { - super('ProjectPromptInputPlugin', ['ShadowProjectInputPlugin']) - } - - async collect(ctx: InputPluginContext): Promise> { - const {dependencyContext, fs, userConfigOptions: options, path, globalScope} = ctx - const {shadowProjectDir} = this.resolveBasePaths(options) - - const shadowProjectsDir = this.resolveShadowPath(options.shadowSourceProject.project.dist, shadowProjectDir) - - const dependencyWorkspace = dependencyContext.workspace - if (dependencyWorkspace == null) { - this.log.warn('No workspace found in dependency context, skipping project prompt enhancement') - return {} - } - - const projects = dependencyWorkspace.projects ?? [] - - const enhancedProjects = await Promise.all(projects.map(async project => { - const projectName = project.name - if (projectName == null) return project - - const shadowProjectPath = path.join(shadowProjectsDir, projectName) - if (!fs.existsSync(shadowProjectPath) || !fs.statSync(shadowProjectPath).isDirectory()) return project - - const targetProjectPath = project.dirFromWorkspacePath?.getAbsolutePath() - - const rootMemoryPrompt = await this.readRootMemoryPrompt(ctx, shadowProjectPath, globalScope) - const childMemoryPrompts = targetProjectPath != null - ? await this.scanChildMemoryPrompts(ctx, shadowProjectPath, targetProjectPath, globalScope) - : [] - - return { - ...project, - ...rootMemoryPrompt != null && {rootMemoryPrompt}, - ...childMemoryPrompts.length > 0 && {childMemoryPrompts} - } - })) - - return { - workspace: { - directory: dependencyWorkspace.directory, - projects: enhancedProjects - } - } - } - - private async readRootMemoryPrompt( - ctx: InputPluginContext, - projectPath: string, - globalScope: InputPluginContext['globalScope'] - ): Promise { - const {fs, path, logger} = ctx - const filePath = path.join(projectPath, PROJECT_MEMORY_FILE) - - if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return - - try { - const rawContent = fs.readFileSync(filePath, 'utf8') - const parsed = parseMarkdown(rawContent) - - let content: string - try { - content = await mdxToMd(rawContent, {globalScope, basePath: projectPath}) - } - catch (e) { - if (e instanceof ScopeError) { - logger.error(`MDX compilation failed in ${filePath}: ${e.message}`) - logger.error(`Please check your configuration file (~/.aindex/.tnmsc.json) and ensure all required variables are defined.`) - process.exit(1) - } - throw e - } - - return { - type: PromptKind.ProjectRootMemory, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - ...parsed.yamlFrontMatter != null && {yamlFrontMatter: parsed.yamlFrontMatter}, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - dir: { - pathKind: FilePathKind.Root, - path: '', - getDirectoryName: () => '' - } - } - } - catch (e) { - logger.error(`Failed to read root memory prompt at ${filePath}`, {error: e}) - return void 0 - } - } - - private async scanChildMemoryPrompts( - ctx: InputPluginContext, - shadowProjectPath: string, - targetProjectPath: string, - globalScope: InputPluginContext['globalScope'] - ): Promise { - const {logger} = ctx - const prompts: ProjectChildrenMemoryPrompt[] = [] - - try { - await this.scanDirectoryRecursive(ctx, shadowProjectPath, shadowProjectPath, targetProjectPath, prompts, globalScope) - } - catch (e) { - logger.error(`Failed to scan child memory prompts at ${shadowProjectPath}`, {error: e}) - } - - return prompts - } - - private async scanDirectoryRecursive( - ctx: InputPluginContext, - shadowProjectPath: string, - currentPath: string, - targetProjectPath: string, - prompts: ProjectChildrenMemoryPrompt[], - globalScope: InputPluginContext['globalScope'] - ): Promise { - const {fs, path} = ctx - - const entries = fs.readdirSync(currentPath, {withFileTypes: true}) - for (const entry of entries) { - if (!entry.isDirectory()) continue - - if (SCAN_SKIP_DIRECTORIES.includes(entry.name)) continue - - const childDir = path.join(currentPath, entry.name) - const memoryFile = path.join(childDir, PROJECT_MEMORY_FILE) - - if (Boolean(fs.existsSync(memoryFile)) && Boolean(fs.statSync(memoryFile).isFile())) { - const prompt = await this.readChildMemoryPrompt(ctx, shadowProjectPath, childDir, targetProjectPath, globalScope) - if (prompt != null) prompts.push(prompt) - } - - await this.scanDirectoryRecursive(ctx, shadowProjectPath, childDir, targetProjectPath, prompts, globalScope) - } - } - - private async readChildMemoryPrompt( - ctx: InputPluginContext, - shadowProjectPath: string, - shadowChildDir: string, - targetProjectPath: string, - globalScope: InputPluginContext['globalScope'] - ): Promise { - const {fs, path, logger} = ctx - const filePath = path.join(shadowChildDir, PROJECT_MEMORY_FILE) - - try { - const rawContent = fs.readFileSync(filePath, 'utf8') - const parsed = parseMarkdown(rawContent) - - let content: string - try { - content = await mdxToMd(rawContent, {globalScope, basePath: shadowChildDir}) - } - catch (e) { - if (e instanceof ScopeError) { - logger.error(`MDX compilation failed in ${filePath}: ${e.message}`) - logger.error(`Please check your configuration file (~/.aindex/.tnmsc.json) and ensure all required variables are defined.`) - process.exit(1) - } - throw e - } - - const relativePath = path.relative(shadowProjectPath, shadowChildDir) - const targetChildDir = path.join(targetProjectPath, relativePath) - const dirName = path.basename(shadowChildDir) - - return { - type: PromptKind.ProjectChildrenMemory, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - ...parsed.yamlFrontMatter != null && {yamlFrontMatter: parsed.yamlFrontMatter}, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - dir: { - pathKind: FilePathKind.Relative, - path: relativePath, - basePath: targetProjectPath, - getDirectoryName: () => dirName, - getAbsolutePath: () => targetChildDir - }, - workingChildDirectoryPath: { - pathKind: FilePathKind.Relative, - path: relativePath, - basePath: targetProjectPath, - getDirectoryName: () => dirName, - getAbsolutePath: () => targetChildDir - } - } - } - catch (e) { - logger.error(`Failed to read child memory prompt at ${filePath}`, {error: e}) - return void 0 - } - } -} diff --git a/packages/plugin-input-project-prompt/src/index.ts b/packages/plugin-input-project-prompt/src/index.ts deleted file mode 100644 index 697fcfee..00000000 --- a/packages/plugin-input-project-prompt/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - ProjectPromptInputPlugin -} from './ProjectPromptInputPlugin' diff --git a/packages/plugin-input-project-prompt/tsconfig.eslint.json b/packages/plugin-input-project-prompt/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-project-prompt/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-project-prompt/tsconfig.json b/packages/plugin-input-project-prompt/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-project-prompt/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-project-prompt/tsconfig.lib.json b/packages/plugin-input-project-prompt/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-project-prompt/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-project-prompt/tsconfig.test.json b/packages/plugin-input-project-prompt/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-project-prompt/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-project-prompt/tsdown.config.ts b/packages/plugin-input-project-prompt/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-project-prompt/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-project-prompt/vite.config.ts b/packages/plugin-input-project-prompt/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-project-prompt/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-project-prompt/vitest.config.ts b/packages/plugin-input-project-prompt/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-project-prompt/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-readme/eslint.config.ts b/packages/plugin-input-readme/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-readme/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-readme/package.json b/packages/plugin-input-readme/package.json deleted file mode 100644 index 85191734..00000000 --- a/packages/plugin-input-readme/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-readme", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-readme for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-readme/src/ReadmeMdInputPlugin.property.test.ts b/packages/plugin-input-readme/src/ReadmeMdInputPlugin.property.test.ts deleted file mode 100644 index 630fba39..00000000 --- a/packages/plugin-input-readme/src/ReadmeMdInputPlugin.property.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import type {InputPluginContext, PluginOptions, ReadmeFileKind} from '@truenine/plugin-shared' - -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {createLogger, README_FILE_KIND_MAP} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' -import {ReadmeMdInputPlugin} from './ReadmeMdInputPlugin' - -/** - * Feature: readme-md-plugin - * Property-based tests for ReadmeMdInputPlugin - */ -describe('readmeMdInputPlugin property tests', () => { - const plugin = new ReadmeMdInputPlugin() - - const allFileKinds = Object.keys(README_FILE_KIND_MAP) as ReadmeFileKind[] - - function createMockContext(workspaceDir: string, _shadowProjectDir: string): InputPluginContext { - const options: PluginOptions = { - workspaceDir, - shadowSourceProject: { - name: '.', - skill: {src: 'src/skills', dist: 'dist/skills'}, - fastCommand: {src: 'src/commands', dist: 'dist/commands'}, - subAgent: {src: 'src/agents', dist: 'dist/agents'}, - rule: {src: 'src/rules', dist: 'dist/rules'}, - globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, - workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, - project: {src: 'app', dist: 'ref'} - } - } - - return { - userConfigOptions: options, - logger: createLogger('test', 'error'), - fs, - path - } - } - - function createDirectoryStructure( - baseDir: string, - structure: Record - ): void { - for (const [filePath, content] of Object.entries(structure)) { - const fullPath = path.join(baseDir, filePath) - const dir = path.dirname(fullPath) - - if (!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true}) - - if (content !== null) fs.writeFileSync(fullPath, content, 'utf8') - } - } - - async function withTempDir(fn: (tempDir: string) => Promise): Promise { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'readme-test-')) - try { - return await fn(tempDir) - } - finally { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - } - - describe('property 1: README Discovery Completeness', () => { - const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) - .filter(s => s.trim().length > 0) - - const fileKindArb = fc.constantFrom(...allFileKinds) - - it('should discover all rdm.mdx files in generated directory structures', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(projectNameArb, {minLength: 1, maxLength: 3}), - fc.array(subdirNameArb, {minLength: 0, maxLength: 2}), - fc.boolean(), - readmeContentArb, - async (projectNames, subdirs, includeRoot, content) => { - await withTempDir(async tempDir => { - const uniqueProjects = [...new Set(projectNames.map(p => p.toLowerCase()))] - const uniqueSubdirs = [...new Set(subdirs.map(s => s.toLowerCase()))] - - const structure: Record = {} - const expectedReadmes: {projectName: string, isRoot: boolean, subdir?: string}[] = [] - - for (const projectName of uniqueProjects) { - structure[`ref/${projectName}/.gitkeep`] = '' - - if (includeRoot) { - structure[`ref/${projectName}/rdm.mdx`] = content - expectedReadmes.push({projectName, isRoot: true}) - } - - for (const subdir of uniqueSubdirs) { - structure[`ref/${projectName}/${subdir}/rdm.mdx`] = content - expectedReadmes.push({projectName, isRoot: false, subdir}) - } - } - - createDirectoryStructure(tempDir, structure) - - const ctx = createMockContext(tempDir, tempDir) - const result = await plugin.collect(ctx) - - const readmePrompts = result.readmePrompts ?? [] - - expect(readmePrompts.length).toBe(expectedReadmes.length) - - for (const expected of expectedReadmes) { - const found = readmePrompts.find( - r => - r.projectName === expected.projectName - && r.isRoot === expected.isRoot - && r.fileKind === 'Readme' - ) - expect(found).toBeDefined() - expect(found?.content).toBe(content) - } - }) - } - ), - {numRuns: 50} - ) - }) - - it('should return empty result when shadow source directory does not exist', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - async projectName => { - await withTempDir(async tempDir => { - const workspaceDir = path.join(tempDir, projectName) - fs.mkdirSync(workspaceDir, {recursive: true}) - - const ctx = createMockContext(workspaceDir, workspaceDir) - const result = await plugin.collect(ctx) - - expect(result.readmePrompts).toEqual([]) - }) - } - ), - {numRuns: 100} - ) - }) - - it('should discover all three file kinds (rdm.mdx, coc.mdx, security.mdx)', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - fc.subarray(allFileKinds, {minLength: 1}), - async (projectName, content, fileKinds) => { - await withTempDir(async tempDir => { - const structure: Record = {} - - for (const kind of fileKinds) { - const srcFile = README_FILE_KIND_MAP[kind].src - structure[`ref/${projectName}/${srcFile}`] = `${content}-${kind}` - } - - createDirectoryStructure(tempDir, structure) - - const ctx = createMockContext(tempDir, tempDir) - const result = await plugin.collect(ctx) - const readmePrompts = result.readmePrompts ?? [] - - expect(readmePrompts.length).toBe(fileKinds.length) - - for (const kind of fileKinds) { - const found = readmePrompts.find(r => r.fileKind === kind) - expect(found).toBeDefined() - expect(found?.content).toBe(`${content}-${kind}`) - expect(found?.projectName).toBe(projectName) - expect(found?.isRoot).toBe(true) - } - }) - } - ), - {numRuns: 100} - ) - }) - - it('should assign correct fileKind for each source file', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - fileKindArb, - readmeContentArb, - async (projectName, fileKind, content) => { - await withTempDir(async tempDir => { - const srcFile = README_FILE_KIND_MAP[fileKind].src - const structure: Record = { - [`ref/${projectName}/${srcFile}`]: content - } - - createDirectoryStructure(tempDir, structure) - - const ctx = createMockContext(tempDir, tempDir) - const result = await plugin.collect(ctx) - const readmePrompts = result.readmePrompts ?? [] - - expect(readmePrompts.length).toBe(1) - expect(readmePrompts[0].fileKind).toBe(fileKind) - expect(readmePrompts[0].content).toBe(content) - }) - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 2: Data Structure Correctness', () => { - const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) - .filter(s => s.trim().length > 0) - - it('should correctly set isRoot flag based on file location', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - subdirNameArb, - readmeContentArb, - readmeContentArb, - async (projectName, subdir, rootContent, childContent) => { - await withTempDir(async tempDir => { - const structure: Record = { - [`ref/${projectName}/rdm.mdx`]: rootContent, - [`ref/${projectName}/${subdir}/rdm.mdx`]: childContent - } - - createDirectoryStructure(tempDir, structure) - - const ctx = createMockContext(tempDir, tempDir) - const result = await plugin.collect(ctx) - const readmePrompts = result.readmePrompts ?? [] - - const rootReadme = readmePrompts.find(r => r.isRoot) - expect(rootReadme).toBeDefined() - expect(rootReadme?.projectName).toBe(projectName) - expect(rootReadme?.content).toBe(rootContent) - expect(rootReadme?.targetDir.path).toBe(projectName) - - const childReadme = readmePrompts.find(r => !r.isRoot) - expect(childReadme).toBeDefined() - expect(childReadme?.projectName).toBe(projectName) - expect(childReadme?.content).toBe(childContent) - expect(childReadme?.targetDir.path).toBe(path.join(projectName, subdir)) - }) - } - ), - {numRuns: 100} - ) - }) - - it('should preserve content exactly as read from file', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - await withTempDir(async tempDir => { - const structure: Record = { - [`ref/${projectName}/rdm.mdx`]: content - } - - createDirectoryStructure(tempDir, structure) - - const ctx = createMockContext(tempDir, tempDir) - const result = await plugin.collect(ctx) - const readmePrompts = result.readmePrompts ?? [] - - expect(readmePrompts.length).toBe(1) - expect(readmePrompts[0].content).toBe(content) - expect(readmePrompts[0].length).toBe(content.length) - }) - } - ), - {numRuns: 100} - ) - }) - - it('should correctly set targetDir with proper path structure', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - fc.array(subdirNameArb, {minLength: 1, maxLength: 3}), - readmeContentArb, - async (projectName, subdirs, content) => { - await withTempDir(async tempDir => { - const uniqueSubdirs = [...new Set(subdirs)] - const structure: Record = {} - - for (const subdir of uniqueSubdirs) structure[`ref/${projectName}/${subdir}/rdm.mdx`] = content - - createDirectoryStructure(tempDir, structure) - - const ctx = createMockContext(tempDir, tempDir) - const result = await plugin.collect(ctx) - const readmePrompts = result.readmePrompts ?? [] - - for (const readme of readmePrompts) { - expect(readme.targetDir.basePath).toBe(tempDir) - expect(readme.targetDir.getAbsolutePath()).toBe( - path.resolve(tempDir, readme.targetDir.path) - ) - } - }) - } - ), - {numRuns: 100} - ) - }) - - it('should discover coc.mdx and security.mdx in child directories', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - subdirNameArb, - readmeContentArb, - async (projectName, subdir, content) => { - await withTempDir(async tempDir => { - const structure: Record = { - [`ref/${projectName}/${subdir}/coc.mdx`]: `coc-${content}`, - [`ref/${projectName}/${subdir}/security.mdx`]: `sec-${content}` - } - - createDirectoryStructure(tempDir, structure) - - const ctx = createMockContext(tempDir, tempDir) - const result = await plugin.collect(ctx) - const readmePrompts = result.readmePrompts ?? [] - - expect(readmePrompts.length).toBe(2) - - const cocPrompt = readmePrompts.find(r => r.fileKind === 'CodeOfConduct') - expect(cocPrompt).toBeDefined() - expect(cocPrompt?.isRoot).toBe(false) - expect(cocPrompt?.content).toBe(`coc-${content}`) - - const secPrompt = readmePrompts.find(r => r.fileKind === 'Security') - expect(secPrompt).toBeDefined() - expect(secPrompt?.isRoot).toBe(false) - expect(secPrompt?.content).toBe(`sec-${content}`) - }) - } - ), - {numRuns: 100} - ) - }) - }) -}) diff --git a/packages/plugin-input-readme/src/ReadmeMdInputPlugin.ts b/packages/plugin-input-readme/src/ReadmeMdInputPlugin.ts deleted file mode 100644 index 28460926..00000000 --- a/packages/plugin-input-readme/src/ReadmeMdInputPlugin.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type {CollectedInputContext, InputPluginContext, ReadmeFileKind, ReadmePrompt, RelativePath} from '@truenine/plugin-shared' - -import process from 'node:process' - -import {mdxToMd} from '@truenine/md-compiler' -import {ScopeError} from '@truenine/md-compiler/errors' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {FilePathKind, PromptKind, README_FILE_KIND_MAP} from '@truenine/plugin-shared' - -const ALL_FILE_KINDS = Object.entries(README_FILE_KIND_MAP) as [ReadmeFileKind, {src: string, out: string}][] - -/** - * Input plugin for collecting readme-family mdx files from shadow project directories. - * Scans dist/app/ directories for rdm.mdx, coc.mdx, security.mdx files - * and collects them as ReadmePrompt objects. - * - * Supports both root files (in project root) and child files (in subdirectories). - * - * Source → Output mapping: - * - rdm.mdx → README.md - * - coc.mdx → CODE_OF_CONDUCT.md - * - security.mdx → SECURITY.md - */ -export class ReadmeMdInputPlugin extends AbstractInputPlugin { - constructor() { - super('ReadmeMdInputPlugin', ['ShadowProjectInputPlugin']) - } - - async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger, fs, path, globalScope} = ctx - const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(options) - - const shadowProjectsDir = this.resolveShadowPath(options.shadowSourceProject.project.dist, shadowProjectDir) - - const readmePrompts: ReadmePrompt[] = [] - - if (!fs.existsSync(shadowProjectsDir) || !fs.statSync(shadowProjectsDir).isDirectory()) { - logger.debug('shadow projects directory does not exist', {path: shadowProjectsDir}) - return {readmePrompts} - } - - try { - const projectEntries = fs.readdirSync(shadowProjectsDir, {withFileTypes: true}) - - for (const projectEntry of projectEntries) { - if (!projectEntry.isDirectory()) continue - - const projectName = projectEntry.name - const projectDir = path.join(shadowProjectsDir, projectName) - - await this.collectReadmeFiles( - ctx, - projectDir, - projectName, - workspaceDir, - '', - readmePrompts, - globalScope - ) - } - } - catch (e) { - logger.error('failed to scan shadow projects', {path: shadowProjectsDir, error: e}) - } - - return {readmePrompts} - } - - private async collectReadmeFiles( - ctx: InputPluginContext, - currentDir: string, - projectName: string, - workspaceDir: string, - relativePath: string, - readmePrompts: ReadmePrompt[], - globalScope: InputPluginContext['globalScope'] - ): Promise { - const {fs, path, logger} = ctx - const isRoot = relativePath === '' - - for (const [fileKind, {src}] of ALL_FILE_KINDS) { - const filePath = path.join(currentDir, src) - if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) continue - - try { - const rawContent = fs.readFileSync(filePath, 'utf8') - - let content: string - if (globalScope != null) { - try { - content = await mdxToMd(rawContent, {globalScope, basePath: currentDir}) - } - catch (e) { - if (e instanceof ScopeError) { - logger.error(`MDX compilation failed in ${filePath}: ${(e as Error).message}`) - logger.error(`Please check your configuration file (~/.aindex/.tnmsc.json) and ensure all required variables are defined.`) - process.exit(1) - } - throw e - } - } else content = rawContent - - const targetPath = isRoot ? projectName : path.join(projectName, relativePath) - - const targetDir: RelativePath = { - pathKind: FilePathKind.Relative, - path: targetPath, - basePath: workspaceDir, - getDirectoryName: () => isRoot ? projectName : path.basename(relativePath), - getAbsolutePath: () => path.resolve(workspaceDir, targetPath) - } - - const dir: RelativePath = { - pathKind: FilePathKind.Relative, - path: path.dirname(filePath), - basePath: workspaceDir, - getDirectoryName: () => path.basename(path.dirname(filePath)), - getAbsolutePath: () => path.dirname(filePath) - } - - readmePrompts.push({ - type: PromptKind.Readme, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - projectName, - targetDir, - isRoot, - fileKind, - markdownContents: [], - dir - }) - } - catch (e) { - logger.warn('failed to read readme-family file', {path: filePath, fileKind, error: e}) - } - } - - try { - const entries = fs.readdirSync(currentDir, {withFileTypes: true}) - - for (const entry of entries) { - if (entry.isDirectory()) { - const subRelativePath = isRoot ? entry.name : path.join(relativePath, entry.name) - const subDir = path.join(currentDir, entry.name) - - await this.collectReadmeFiles(ctx, subDir, projectName, workspaceDir, subRelativePath, readmePrompts, globalScope) - } - } - } - catch (e) { - logger.warn('failed to scan directory', {path: currentDir, error: e}) - } - } -} diff --git a/packages/plugin-input-readme/src/index.ts b/packages/plugin-input-readme/src/index.ts deleted file mode 100644 index b3b2628f..00000000 --- a/packages/plugin-input-readme/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - ReadmeMdInputPlugin -} from './ReadmeMdInputPlugin' diff --git a/packages/plugin-input-readme/tsconfig.eslint.json b/packages/plugin-input-readme/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-readme/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-readme/tsconfig.json b/packages/plugin-input-readme/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-readme/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-readme/tsconfig.lib.json b/packages/plugin-input-readme/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-readme/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-readme/tsconfig.test.json b/packages/plugin-input-readme/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-readme/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-readme/tsdown.config.ts b/packages/plugin-input-readme/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-readme/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-readme/vite.config.ts b/packages/plugin-input-readme/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-readme/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-readme/vitest.config.ts b/packages/plugin-input-readme/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-readme/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-rule/eslint.config.ts b/packages/plugin-input-rule/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-rule/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-rule/package.json b/packages/plugin-input-rule/package.json deleted file mode 100644 index fd207bd8..00000000 --- a/packages/plugin-input-rule/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-rule", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-rule for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-rule/src/RuleInputPlugin.test.ts b/packages/plugin-input-rule/src/RuleInputPlugin.test.ts deleted file mode 100644 index d0786955..00000000 --- a/packages/plugin-input-rule/src/RuleInputPlugin.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import type {ILogger, InputPluginContext, RulePrompt} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {validateRuleMetadata} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {RuleInputPlugin} from './RuleInputPlugin' - -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) - }) - - it('should pass when seriName is a valid string', () => { - const result = validateRuleMetadata({globs: ['**/*.ts'], description: 'desc', scope: 'project', seriName: 'uniapp3'}) - expect(result.valid).toBe(true) - expect(result.errors).toHaveLength(0) - }) - - it('should pass when seriName is absent', () => { - const result = validateRuleMetadata({globs: ['**/*.ts'], description: 'desc', scope: 'project'}) - expect(result.valid).toBe(true) - }) - - it('should fail when seriName is not a string', () => { - const result = validateRuleMetadata({globs: ['**/*.ts'], description: 'desc', scope: 'project', seriName: 42}) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('seriName'))).toBe(true) - }) - - it('should fail when seriName is an object', () => { - const result = validateRuleMetadata({globs: ['**/*.ts'], description: 'desc', seriName: {}}) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('seriName'))).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('ruleInputPlugin - seriName propagation', () => { - let tempDir: string - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rule-seri-')) - fs.mkdirSync(path.join(tempDir, 'shadow', 'rules', 'my-series'), {recursive: true}) - }) - - afterEach(() => fs.rmSync(tempDir, {recursive: true, force: true})) - - function createCtx(): InputPluginContext { - return { - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as unknown as ILogger, - fs, - path, - glob: vi.fn() as never, - userConfigOptions: { - workspaceDir: tempDir, - shadowSourceProject: { - name: 'shadow', - skill: {src: 'src/skills', dist: 'dist/skills'}, - fastCommand: {src: 'src/commands', dist: 'dist/commands'}, - subAgent: {src: 'src/agents', dist: 'dist/agents'}, - rule: {src: 'src/rules', dist: 'rules'}, - globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, - workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, - project: {src: 'app', dist: 'dist/app'} - }, - fastCommandSeriesOptions: {}, - plugins: [], - logLevel: 'info' - } as never, - dependencyContext: {}, - globalScope: { - profile: {name: 'test', username: 'test', gender: 'male', birthday: '2000-01-01'}, - tool: {name: 'test'}, - env: {}, - os: {platform: 'linux', arch: 'x64', homedir: '/home/test'}, - Md: vi.fn() - } as never - } - } - - it('should propagate seriName from YAML front matter to RulePrompt', async () => { - fs.writeFileSync( - path.join(tempDir, 'shadow', 'rules', 'my-series', 'my-rule.mdx'), - ['---', 'globs: ["**/*.ts"]', 'description: Test rule', 'scope: project', 'seriName: uniapp3', 'namingCase: kebab-case', '---', '', '# Rule'].join('\n') - ) - const result = await new RuleInputPlugin().collect(createCtx()) - const rule = result.rules?.[0] - expect(rule?.seriName).toBe('uniapp3') - }) - - it('should leave seriName undefined when not in front matter', async () => { - fs.writeFileSync( - path.join(tempDir, 'shadow', 'rules', 'my-series', 'no-seri.mdx'), - ['---', 'globs: ["**/*.ts"]', 'description: No seri', 'scope: project', 'namingCase: kebab-case', '---', '', '# Rule'].join('\n') - ) - const result = await new RuleInputPlugin().collect(createCtx()) - const rule = result.rules?.[0] - expect(rule?.seriName).toBeUndefined() - }) -}) - -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/packages/plugin-input-rule/src/RuleInputPlugin.ts b/packages/plugin-input-rule/src/RuleInputPlugin.ts deleted file mode 100644 index 28842b48..00000000 --- a/packages/plugin-input-rule/src/RuleInputPlugin.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { - CollectedInputContext, - InputPluginContext, - MetadataValidationResult, - PluginOptions, - ResolvedBasePaths, - RulePrompt, - RuleScope, - RuleYAMLFrontMatter -} from '@truenine/plugin-shared' -import {mdxToMd} from '@truenine/md-compiler' -import {MetadataValidationError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {BaseDirectoryInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind, - PromptKind, - validateRuleMetadata -} from '@truenine/plugin-shared' - -export class RuleInputPlugin extends BaseDirectoryInputPlugin { - constructor() { - super('RuleInputPlugin', {configKey: 'shadowSourceProject.rule.dist'}) - } - - protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { - return this.resolveShadowPath(options.shadowSourceProject.rule.dist, resolvedPaths.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' - const seriName = yamlFrontMatter?.seriName - - 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, - ...seriName != null && {seriName}, - 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/packages/plugin-input-rule/src/index.ts b/packages/plugin-input-rule/src/index.ts deleted file mode 100644 index aa91bf07..00000000 --- a/packages/plugin-input-rule/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - RuleInputPlugin -} from './RuleInputPlugin' diff --git a/packages/plugin-input-rule/tsconfig.eslint.json b/packages/plugin-input-rule/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-rule/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-rule/tsconfig.json b/packages/plugin-input-rule/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-rule/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-rule/tsconfig.lib.json b/packages/plugin-input-rule/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-rule/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-rule/tsconfig.test.json b/packages/plugin-input-rule/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-rule/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-rule/tsdown.config.ts b/packages/plugin-input-rule/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-rule/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-rule/vite.config.ts b/packages/plugin-input-rule/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-rule/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-rule/vitest.config.ts b/packages/plugin-input-rule/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-rule/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-shadow-project/eslint.config.ts b/packages/plugin-input-shadow-project/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-shadow-project/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-shadow-project/package.json b/packages/plugin-input-shadow-project/package.json deleted file mode 100644 index ddbbdec3..00000000 --- a/packages/plugin-input-shadow-project/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-shadow-project", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-shadow-project for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "jsonc-parser": "catalog:" - } -} diff --git a/packages/plugin-input-shadow-project/src/ShadowProjectInputPlugin.test.ts b/packages/plugin-input-shadow-project/src/ShadowProjectInputPlugin.test.ts deleted file mode 100644 index b0380f19..00000000 --- a/packages/plugin-input-shadow-project/src/ShadowProjectInputPlugin.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type {ILogger, InputPluginContext, PluginOptions} from '@truenine/plugin-shared' -import * as path from 'node:path' -import * as fc from 'fast-check' -import {describe, expect, it, vi} from 'vitest' -import {ShadowProjectInputPlugin} from './ShadowProjectInputPlugin' - -const W = '/workspace' -const SHADOW = 'shadow' -const SHADOW_DIR = path.join(W, SHADOW) -const DIST_APP = path.join(SHADOW_DIR, 'dist/app') -const SRC_APP = path.join(SHADOW_DIR, 'app') - -function mockLogger(): ILogger { - return {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as unknown as ILogger -} - -function mockOptions(): Required { - return { - workspaceDir: W, - shadowSourceProject: { - name: SHADOW, - skill: {src: 'src/skills', dist: 'dist/skills'}, - fastCommand: {src: 'src/commands', dist: 'dist/commands'}, - subAgent: {src: 'src/agents', dist: 'dist/agents'}, - rule: {src: 'src/rules', dist: 'dist/rules'}, - globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, - workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, - project: {src: 'app', dist: 'dist/app'} - }, - fastCommandSeriesOptions: {}, - plugins: [], - logLevel: 'info' - } as never -} - -function projectJsoncPath(name: string): string { - return path.join(SRC_APP, name, 'project.jsonc') -} - -function makeDirEntry(name: string) { - return {name, isDirectory: () => true, isFile: () => false} -} - -function createCtx(mockFs: unknown, logger = mockLogger()): InputPluginContext { - return { - logger, - fs: mockFs as typeof import('node:fs'), - path, - glob: vi.fn() as never, - userConfigOptions: mockOptions(), - dependencyContext: {}, - globalScope: void 0 as never - } -} - -function buildMockFs(projectName: string, jsoncContent: string | null) { - const jsoncPath = projectJsoncPath(projectName) - return { - existsSync: vi.fn((p: string) => { - if (p === DIST_APP) return true - if (p === jsoncPath) return jsoncContent != null - return false - }), - statSync: vi.fn(() => ({isDirectory: () => true})), - readdirSync: vi.fn((p: string) => p === DIST_APP ? [makeDirEntry(projectName)] : []), - readFileSync: vi.fn((p: string) => { - if (p === jsoncPath && jsoncContent != null) return jsoncContent - throw new Error(`unexpected readFileSync: ${p}`) - }) - } -} - -describe('shadowProjectInputPlugin - project.jsonc loading', () => { - it('attaches projectConfig when project.jsonc exists', () => { - const config = {rules: {includeSeries: ['uniapp3']}} - const mockFs = buildMockFs('my-project', JSON.stringify(config)) - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) - const project = result.workspace?.projects.find(p => p.name === 'my-project') - expect(project?.projectConfig).toEqual(config) - }) - - it('leaves projectConfig undefined when project.jsonc is absent', () => { - const mockFs = buildMockFs('my-project', null) - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) - const project = result.workspace?.projects.find(p => p.name === 'my-project') - expect(project?.projectConfig).toBeUndefined() - }) - - it('parses JSONC with comments correctly', () => { - const jsonc = '{\n // enable uniapp rules\n "rules": {"includeSeries": ["uniapp3"]}\n}' - const mockFs = buildMockFs('proj', jsonc) - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) - expect(result.workspace?.projects[0]?.projectConfig?.rules?.includeSeries).toEqual(['uniapp3']) - }) - - it('leaves projectConfig undefined and warns on malformed JSONC', () => { - const logger = mockLogger() - const mockFs = buildMockFs('proj', '{invalid json{{') - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs, logger)) - expect(result.workspace?.projects[0]?.projectConfig).toBeUndefined() - }) - - it('attaches mcp.names from project.jsonc', () => { - const config = {mcp: {names: ['context7', 'deepwiki']}} - const mockFs = buildMockFs('proj', JSON.stringify(config)) - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) - expect(result.workspace?.projects[0]?.projectConfig?.mcp?.names).toEqual(['context7', 'deepwiki']) - }) - - it('attaches rules.subSeries from project.jsonc', () => { - const config = {rules: {subSeries: {backend: ['api-rules'], frontend: ['vue-rules']}}} - const mockFs = buildMockFs('proj', JSON.stringify(config)) - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) - expect(result.workspace?.projects[0]?.projectConfig?.rules?.subSeries).toEqual(config.rules.subSeries) - }) - - it('does not affect other project fields when project.jsonc is absent', () => { - const mockFs = buildMockFs('proj', null) - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) - const p = result.workspace?.projects[0] - expect(p?.name).toBe('proj') - expect(p?.dirFromWorkspacePath).toBeDefined() - expect(p?.projectConfig).toBeUndefined() - }) - - it('handles empty project.jsonc object', () => { - const mockFs = buildMockFs('proj', '{}') - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) - expect(result.workspace?.projects[0]?.projectConfig).toEqual({}) - }) -}) - -describe('shadowProjectInputPlugin - project.jsonc property tests', () => { - const projectNameGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,19}$/) - const stringArrayGen = fc.array(fc.string({minLength: 1, maxLength: 20}), {maxLength: 5}) - - it('projectConfig is always attached when project.jsonc exists with valid JSON', () => { - fc.assert(fc.property(projectNameGen, stringArrayGen, (name, include) => { - const config = {rules: {include}} - const mockFs = buildMockFs(name, JSON.stringify(config)) - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) - const project = result.workspace?.projects.find(p => p.name === name) - expect(project?.projectConfig?.rules?.include).toEqual(include) - })) - }) - - it('projectConfig is always undefined when project.jsonc is absent', () => { - fc.assert(fc.property(projectNameGen, name => { - const mockFs = buildMockFs(name, null) - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) - const project = result.workspace?.projects.find(p => p.name === name) - expect(project?.projectConfig).toBeUndefined() - })) - }) - - it('project name is always preserved regardless of projectConfig presence', () => { - fc.assert(fc.property(projectNameGen, fc.boolean(), (name, hasConfig) => { - const mockFs = buildMockFs(name, hasConfig ? '{"mcp": {"names": []}}' : null) - const result = new ShadowProjectInputPlugin().collect(createCtx(mockFs)) - const project = result.workspace?.projects.find(p => p.name === name) - expect(project?.name).toBe(name) - })) - }) -}) diff --git a/packages/plugin-input-shadow-project/src/ShadowProjectInputPlugin.ts b/packages/plugin-input-shadow-project/src/ShadowProjectInputPlugin.ts deleted file mode 100644 index ffcc7e2f..00000000 --- a/packages/plugin-input-shadow-project/src/ShadowProjectInputPlugin.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type {CollectedInputContext, InputPluginContext, Project, Workspace} from '@truenine/plugin-shared' -import type {ProjectConfig} from '@truenine/plugin-shared/types' - -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind -} from '@truenine/plugin-shared' -import {parse as parseJsonc} from 'jsonc-parser' - -export class ShadowProjectInputPlugin extends AbstractInputPlugin { - constructor() { - super('ShadowProjectInputPlugin') - } - - private loadProjectConfig( - projectName: string, - shadowProjectDir: string, - srcPath: string, - fs: InputPluginContext['fs'], - path: InputPluginContext['path'], - logger: InputPluginContext['logger'] - ): ProjectConfig | undefined { - const configPath = path.join(shadowProjectDir, srcPath, projectName, 'project.jsonc') - if (!fs.existsSync(configPath)) return void 0 - try { - const raw = fs.readFileSync(configPath, 'utf8') - const errors: import('jsonc-parser').ParseError[] = [] - const result = parseJsonc(raw, errors) as ProjectConfig - if (errors.length > 0) { - logger.warn(`failed to parse project.jsonc for ${projectName}`, {path: configPath, errors}) - return void 0 - } - return result - } catch (e) { - logger.warn(`failed to parse project.jsonc for ${projectName}`, {path: configPath, error: e}) - return void 0 - } - } - - collect(ctx: InputPluginContext): Partial { - const {userConfigOptions: options, logger, fs, path} = ctx - const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(options) - - const shadowProjectsDir = this.resolveShadowPath(options.shadowSourceProject.project.dist, shadowProjectDir) - - const shadowSourceProjectName = path.basename(shadowProjectDir) - - const shadowProjects: Project[] = [] - - if (fs.existsSync(shadowProjectsDir) && fs.statSync(shadowProjectsDir).isDirectory()) { - try { - const entries = fs.readdirSync(shadowProjectsDir, {withFileTypes: true}) - for (const entry of entries) { - if (entry.isDirectory()) { - const isTheShadowSourceProject = entry.name === shadowSourceProjectName - const projectConfig = this.loadProjectConfig(entry.name, shadowProjectDir, options.shadowSourceProject.project.src, fs, path, logger) - - shadowProjects.push({ - name: entry.name, - ...isTheShadowSourceProject && {isPromptSourceProject: true}, - ...projectConfig != null && {projectConfig}, - dirFromWorkspacePath: { - pathKind: FilePathKind.Relative, - path: entry.name, - basePath: workspaceDir, - getDirectoryName: () => entry.name, - getAbsolutePath: () => path.resolve(workspaceDir, entry.name) - } - }) - } - } - } - catch (e) { - logger.error('failed to scan shadow projects', {path: shadowProjectsDir, error: e}) - } - } - - if (shadowProjects.length === 0 && fs.existsSync(workspaceDir) && fs.statSync(workspaceDir).isDirectory()) { - logger.debug('no projects in dist/app/, falling back to workspace scan', {workspaceDir}) - try { - const entries = fs.readdirSync(workspaceDir, {withFileTypes: true}) - for (const entry of entries) { - if (entry.isDirectory() && !entry.name.startsWith('.')) { - const isTheShadowSourceProject = entry.name === shadowSourceProjectName - const projectConfig = this.loadProjectConfig(entry.name, shadowProjectDir, options.shadowSourceProject.project.src, fs, path, logger) - - shadowProjects.push({ - name: entry.name, - ...isTheShadowSourceProject && {isPromptSourceProject: true}, - ...projectConfig != null && {projectConfig}, - dirFromWorkspacePath: { - pathKind: FilePathKind.Relative, - path: entry.name, - basePath: workspaceDir, - getDirectoryName: () => entry.name, - getAbsolutePath: () => path.resolve(workspaceDir, entry.name) - } - }) - } - } - } - catch (e) { - logger.error('failed to scan workspace directory', {path: workspaceDir, error: e}) - } - } - - const workspace: Workspace = { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir) - }, - projects: shadowProjects - } - - return {workspace} - } -} diff --git a/packages/plugin-input-shadow-project/src/index.ts b/packages/plugin-input-shadow-project/src/index.ts deleted file mode 100644 index 04ecc9ad..00000000 --- a/packages/plugin-input-shadow-project/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - ShadowProjectInputPlugin -} from './ShadowProjectInputPlugin' diff --git a/packages/plugin-input-shadow-project/tsconfig.eslint.json b/packages/plugin-input-shadow-project/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-shadow-project/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-shadow-project/tsconfig.json b/packages/plugin-input-shadow-project/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-shadow-project/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-shadow-project/tsconfig.lib.json b/packages/plugin-input-shadow-project/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-shadow-project/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-shadow-project/tsconfig.test.json b/packages/plugin-input-shadow-project/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-shadow-project/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-shadow-project/tsdown.config.ts b/packages/plugin-input-shadow-project/tsdown.config.ts deleted file mode 100644 index 1e0a730b..00000000 --- a/packages/plugin-input-shadow-project/tsdown.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false}, - external: ['jsonc-parser'] - } -]) diff --git a/packages/plugin-input-shadow-project/vite.config.ts b/packages/plugin-input-shadow-project/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-shadow-project/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-shadow-project/vitest.config.ts b/packages/plugin-input-shadow-project/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-shadow-project/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-shared-ignore/eslint.config.ts b/packages/plugin-input-shared-ignore/eslint.config.ts deleted file mode 100644 index 12cc00fc..00000000 --- a/packages/plugin-input-shared-ignore/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-shared-ignore/package.json b/packages/plugin-input-shared-ignore/package.json deleted file mode 100644 index 83497c82..00000000 --- a/packages/plugin-input-shared-ignore/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-input-shared-ignore", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "AI agent ignore file input plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run --passWithNoTests", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-shared-ignore/src/AIAgentIgnoreInputPlugin.ts b/packages/plugin-input-shared-ignore/src/AIAgentIgnoreInputPlugin.ts deleted file mode 100644 index 2cfb14b4..00000000 --- a/packages/plugin-input-shared-ignore/src/AIAgentIgnoreInputPlugin.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type {AIAgentIgnoreConfigFile, CollectedInputContext, InputPluginContext} from '@truenine/plugin-shared' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {SHADOW_SOURCE_FILE_NAMES} from '@truenine/plugin-shared' - -const IGNORE_FILE_NAMES: readonly string[] = [ - SHADOW_SOURCE_FILE_NAMES.QODER_IGNORE, - SHADOW_SOURCE_FILE_NAMES.CURSOR_IGNORE, - SHADOW_SOURCE_FILE_NAMES.WARP_INDEX_IGNORE, - SHADOW_SOURCE_FILE_NAMES.AI_IGNORE, - SHADOW_SOURCE_FILE_NAMES.CODEIUM_IGNORE, - '.kiroignore', - '.traeignore' -] as const - -/** - * Input plugin that reads AI agent ignore files from shadow source project root. - * Reads files like .kiroignore, .aiignore, .cursorignore, etc. - * and populates aiAgentIgnoreConfigFiles in CollectedInputContext. - */ -export class AIAgentIgnoreInputPlugin extends AbstractInputPlugin { - constructor() { - super('AIAgentIgnoreInputPlugin') - } - - collect(ctx: InputPluginContext): Partial { - const {shadowProjectDir} = this.resolveBasePaths(ctx.userConfigOptions) - const results: AIAgentIgnoreConfigFile[] = [] - - for (const fileName of IGNORE_FILE_NAMES) { - const filePath = ctx.path.join(shadowProjectDir, fileName) - if (!ctx.fs.existsSync(filePath)) { - this.log.debug({action: 'collect', message: 'Ignore file not found', path: filePath}) - continue - } - const content = ctx.fs.readFileSync(filePath, 'utf8') - if (content.length === 0) { - this.log.debug({action: 'collect', message: 'Ignore file is empty', path: filePath}) - continue - } - results.push({fileName, content}) - this.log.debug({action: 'collect', message: 'Loaded ignore file', path: filePath, fileName}) - } - - if (results.length === 0) return {} - return {aiAgentIgnoreConfigFiles: results} - } -} diff --git a/packages/plugin-input-shared-ignore/src/index.ts b/packages/plugin-input-shared-ignore/src/index.ts deleted file mode 100644 index 64bf7709..00000000 --- a/packages/plugin-input-shared-ignore/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - AIAgentIgnoreInputPlugin -} from './AIAgentIgnoreInputPlugin' diff --git a/packages/plugin-input-shared-ignore/tsconfig.json b/packages/plugin-input-shared-ignore/tsconfig.json deleted file mode 100644 index 6ccdd2ab..00000000 --- a/packages/plugin-input-shared-ignore/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "eslint.config.ts", "tsdown.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-shared-ignore/tsconfig.lib.json b/packages/plugin-input-shared-ignore/tsconfig.lib.json deleted file mode 100644 index 2a3b86e2..00000000 --- a/packages/plugin-input-shared-ignore/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-shared-ignore/tsdown.config.ts b/packages/plugin-input-shared-ignore/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-shared-ignore/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-shared/eslint.config.ts b/packages/plugin-input-shared/eslint.config.ts deleted file mode 100644 index 2b7b269c..00000000 --- a/packages/plugin-input-shared/eslint.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' - -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: { - allowDefaultProject: true - } - }, - ignores: [ - '.turbo/**', - '*.md', - '**/*.md' - ] -}) - -export default config as unknown diff --git a/packages/plugin-input-shared/package.json b/packages/plugin-input-shared/package.json deleted file mode 100644 index e1e0f0be..00000000 --- a/packages/plugin-input-shared/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@truenine/plugin-input-shared", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Shared abstract base classes and scope management for memory-sync input plugins", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - }, - "./scope": { - "types": "./dist/scope/index.d.mts", - "import": "./dist/scope/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "fast-glob": "catalog:" - } -} diff --git a/packages/plugin-input-shared/src/AbstractInputPlugin.test.ts b/packages/plugin-input-shared/src/AbstractInputPlugin.test.ts deleted file mode 100644 index b5ba177c..00000000 --- a/packages/plugin-input-shared/src/AbstractInputPlugin.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import type {CollectedInputContext, InputEffectContext, InputEffectResult, InputPluginContext, PluginOptions} from '@truenine/plugin-shared' - -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {createLogger} from '@truenine/plugin-shared' -import glob from 'fast-glob' -import {beforeEach, describe, expect, it} from 'vitest' -import {AbstractInputPlugin} from './AbstractInputPlugin' - -function createTestOptions(overrides: Partial = {}): Required { // Default test options for Required - return { - workspaceDir: '/test', - shadowSourceProject: { - name: 'tnmsc-shadow', - skill: {src: 'src/skills', dist: 'dist/skills'}, - fastCommand: {src: 'src/commands', dist: 'dist/commands'}, - subAgent: {src: 'src/agents', dist: 'dist/agents'}, - rule: {src: 'src/rules', dist: 'dist/rules'}, - globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, - workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, - project: {src: 'app', dist: 'dist/app'} - }, - fastCommandSeriesOptions: {}, - plugins: [], - logLevel: 'info', - ...overrides - } -} - -class TestInputPlugin extends AbstractInputPlugin { // Concrete implementation for testing - public effectResults: InputEffectResult[] = [] - - constructor(name: string = 'TestInputPlugin', dependsOn?: readonly string[]) { - super(name, dependsOn) - } - - async collect(): Promise> { - return {} - } - - public exposeRegisterEffect( // Expose protected methods for testing - name: string, - handler: (ctx: InputEffectContext) => Promise, - priority?: number - ): void { - this.registerEffect(name, handler, priority) - } - - public exposeResolveBasePaths(options: Required): {workspaceDir: string, shadowProjectDir: string} { - return this.resolveBasePaths(options) - } - - public exposeResolvePath(rawPath: string, workspaceDir: string): string { - return this.resolvePath(rawPath, workspaceDir) - } - - public exposeResolveShadowPath(relativePath: string, shadowProjectDir: string): string { - return this.resolveShadowPath(relativePath, shadowProjectDir) - } - - public exposeRegisterScope(namespace: string, values: Record): void { // Expose scope registration methods for testing - this.registerScope(namespace, values) - } - - public exposeClearRegisteredScopes(): void { - this.clearRegisteredScopes() - } -} - -describe('abstractInputPlugin', () => { - let plugin: TestInputPlugin, - mockLogger: ReturnType - - beforeEach(() => { - plugin = new TestInputPlugin() - mockLogger = createLogger('test') - }) - - describe('effect registration', () => { - it('should register effects', () => { - expect(plugin.hasEffects()).toBe(false) - expect(plugin.getEffectCount()).toBe(0) - - plugin.exposeRegisterEffect('test-effect', async () => ({ - success: true, - description: 'Test effect executed' - })) - - expect(plugin.hasEffects()).toBe(true) - expect(plugin.getEffectCount()).toBe(1) - }) - - it('should sort effects by priority', () => { - const executionOrder: string[] = [] - - plugin.exposeRegisterEffect('low-priority', async () => { - executionOrder.push('low') - return {success: true} - }, 10) - - plugin.exposeRegisterEffect('high-priority', async () => { - executionOrder.push('high') - return {success: true} - }, -10) - - plugin.exposeRegisterEffect('default-priority', async () => { - executionOrder.push('default') - return {success: true} - }) - - expect(plugin.getEffectCount()).toBe(3) - }) - }) - - describe('executeEffects', () => { - it('should execute effects in priority order', async () => { - const executionOrder: string[] = [] - - plugin.exposeRegisterEffect('third', async () => { - executionOrder.push('third') - return {success: true} - }, 10) - - plugin.exposeRegisterEffect('first', async () => { - executionOrder.push('first') - return {success: true} - }, -10) - - plugin.exposeRegisterEffect('second', async () => { - executionOrder.push('second') - return {success: true} - }, 0) - - const ctx: InputPluginContext = { - logger: mockLogger, - fs, - path, - glob, - userConfigOptions: createTestOptions({workspaceDir: '/test'}), - dependencyContext: {} - } - - const results = await plugin.executeEffects(ctx) - - expect(results).toHaveLength(3) - expect(results.every(r => r.success)).toBe(true) - expect(executionOrder).toEqual(['first', 'second', 'third']) - }) - - it('should return empty array when no effects registered', async () => { - const ctx: InputPluginContext = { - logger: mockLogger, - fs, - path, - glob, - userConfigOptions: createTestOptions(), - dependencyContext: {} - } - - const results = await plugin.executeEffects(ctx) - expect(results).toHaveLength(0) - }) - - it('should handle dry-run mode', async () => { - let effectExecuted = false - - plugin.exposeRegisterEffect('test-effect', async () => { - effectExecuted = true - return {success: true} - }) - - const ctx: InputPluginContext = { - logger: mockLogger, - fs, - path, - glob, - userConfigOptions: createTestOptions(), - dependencyContext: {} - } - - const results = await plugin.executeEffects(ctx, true) - - expect(results).toHaveLength(1) - expect(results[0]?.success).toBe(true) - expect(results[0]?.description).toContain('Would execute') - expect(effectExecuted).toBe(false) - }) - - it('should catch and log errors from effects', async () => { - plugin.exposeRegisterEffect('failing-effect', async () => { - throw new Error('Effect failed') - }) - - const ctx: InputPluginContext = { - logger: mockLogger, - fs, - path, - glob, - userConfigOptions: createTestOptions(), - dependencyContext: {} - } - - const results = await plugin.executeEffects(ctx) - - expect(results).toHaveLength(1) - expect(results[0]?.success).toBe(false) - expect(results[0]?.error?.message).toBe('Effect failed') - }) - - it('should continue executing effects after one fails', async () => { - const executionOrder: string[] = [] - - plugin.exposeRegisterEffect('first', async () => { - executionOrder.push('first') - throw new Error('First failed') - }, -10) - - plugin.exposeRegisterEffect('second', async () => { - executionOrder.push('second') - return {success: true} - }, 10) - - const ctx: InputPluginContext = { - logger: mockLogger, - fs, - path, - glob, - userConfigOptions: createTestOptions(), - dependencyContext: {} - } - - const results = await plugin.executeEffects(ctx) - - expect(results).toHaveLength(2) - expect(results[0]?.success).toBe(false) - expect(results[1]?.success).toBe(true) - expect(executionOrder).toEqual(['first', 'second']) - }) - }) - - describe('resolveBasePaths', () => { - it('should resolve workspace and shadow project paths', () => { - const options = createTestOptions({workspaceDir: '/custom/workspace'}) - - const {workspaceDir, shadowProjectDir} = plugin.exposeResolveBasePaths(options) - - expect(workspaceDir).toBe(path.normalize('/custom/workspace')) - expect(shadowProjectDir).toBe(path.normalize('/custom/workspace/tnmsc-shadow')) - }) - - it('should construct shadow project dir from name', () => { - const options = createTestOptions({ - workspaceDir: '~/project', - shadowSourceProject: { - name: 'my-shadow', - skill: {src: 'src/skills', dist: 'dist/skills'}, - fastCommand: {src: 'src/commands', dist: 'dist/commands'}, - subAgent: {src: 'src/agents', dist: 'dist/agents'}, - rule: {src: 'src/rules', dist: 'dist/rules'}, - globalMemory: {src: 'app/global.cn.mdx', dist: 'dist/global.mdx'}, - workspaceMemory: {src: 'app/workspace.cn.mdx', dist: 'dist/app/workspace.mdx'}, - project: {src: 'app', dist: 'dist/app'} - } - }) - - const {workspaceDir, shadowProjectDir} = plugin.exposeResolveBasePaths(options) - - expect(workspaceDir).toContain('project') - expect(shadowProjectDir).toContain('my-shadow') - }) - }) - - describe('resolvePath', () => { - it('should replace ~ with home directory', () => { - const resolved = plugin.exposeResolvePath('~/test', '') - expect(resolved).toBe(path.normalize(`${os.homedir()}/test`)) - }) - - it('should replace $WORKSPACE placeholder', () => { - const resolved = plugin.exposeResolvePath('$WORKSPACE/subdir', '/workspace') - expect(resolved).toBe(path.normalize('/workspace/subdir')) - }) - }) - - describe('resolveShadowPath', () => { - it('should join shadow project dir with relative path', () => { - const resolved = plugin.exposeResolveShadowPath('dist/skills', '/shadow') - expect(resolved).toBe(path.join('/shadow', 'dist/skills')) - }) - }) - - describe('scope registration', () => { - it('should register scope variables', () => { - expect(plugin.getRegisteredScopes()).toHaveLength(0) - - plugin.exposeRegisterScope('myPlugin', {version: '1.0.0'}) - - const scopes = plugin.getRegisteredScopes() - expect(scopes).toHaveLength(1) - expect(scopes[0]?.namespace).toBe('myPlugin') - expect(scopes[0]?.values).toEqual({version: '1.0.0'}) - }) - - it('should register multiple scopes', () => { - plugin.exposeRegisterScope('plugin1', {key1: 'value1'}) - plugin.exposeRegisterScope('plugin2', {key2: 'value2'}) - - const scopes = plugin.getRegisteredScopes() - expect(scopes).toHaveLength(2) - expect(scopes[0]?.namespace).toBe('plugin1') - expect(scopes[1]?.namespace).toBe('plugin2') - }) - - it('should allow registering same namespace multiple times', () => { - plugin.exposeRegisterScope('myPlugin', {key1: 'value1'}) - plugin.exposeRegisterScope('myPlugin', {key2: 'value2'}) - - const scopes = plugin.getRegisteredScopes() - expect(scopes).toHaveLength(2) - expect(scopes[0]?.values).toEqual({key1: 'value1'}) - expect(scopes[1]?.values).toEqual({key2: 'value2'}) - }) - - it('should support nested objects in scope values', () => { - plugin.exposeRegisterScope('myPlugin', { - config: { - debug: true, - nested: {level: 2} - } - }) - - const scopes = plugin.getRegisteredScopes() - expect(scopes[0]?.values).toEqual({ - config: { - debug: true, - nested: {level: 2} - } - }) - }) - - it('should clear registered scopes', () => { - plugin.exposeRegisterScope('myPlugin', {key: 'value'}) - expect(plugin.getRegisteredScopes()).toHaveLength(1) - - plugin.exposeClearRegisteredScopes() - expect(plugin.getRegisteredScopes()).toHaveLength(0) - }) - - it('should return readonly array from getRegisteredScopes', () => { - plugin.exposeRegisterScope('myPlugin', {key: 'value'}) - - const scopes = plugin.getRegisteredScopes() - expect(Array.isArray(scopes)).toBe(true) // TypeScript should prevent modification, but we verify the array is a copy - }) - }) -}) diff --git a/packages/plugin-input-shared/src/AbstractInputPlugin.ts b/packages/plugin-input-shared/src/AbstractInputPlugin.ts deleted file mode 100644 index 5466d6c0..00000000 --- a/packages/plugin-input-shared/src/AbstractInputPlugin.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' -import type { - CollectedInputContext, - InputEffectContext, - InputEffectHandler, - InputEffectRegistration, - InputEffectResult, - InputPlugin, - InputPluginContext, - PluginOptions, - PluginScopeRegistration, - ResolvedBasePaths, - YAMLFrontMatter -} from '@truenine/plugin-shared' - -import {spawn} from 'node:child_process' -import * as os from 'node:os' -import * as path from 'node:path' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import { - AbstractPlugin, - PathPlaceholders, - PluginKind -} from '@truenine/plugin-shared' - -export abstract class AbstractInputPlugin extends AbstractPlugin implements InputPlugin { - private readonly inputEffects: InputEffectRegistration[] = [] - - private readonly registeredScopes: PluginScopeRegistration[] = [] - - protected constructor(name: string, dependsOn?: readonly string[]) { - super(name, PluginKind.Input, dependsOn) - } - - protected registerEffect(name: string, handler: InputEffectHandler, priority: number = 0): void { - this.inputEffects.push({name, handler, priority}) - this.inputEffects.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) // Sort by priority (lower = earlier) - } - - async executeEffects(ctx: InputPluginContext, dryRun: boolean = false): Promise { - const results: InputEffectResult[] = [] - - if (this.inputEffects.length === 0) return results - - const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(ctx.userConfigOptions) - - const effectCtx: InputEffectContext = { - logger: this.log, - fs: ctx.fs, - path: ctx.path, - glob: ctx.glob, - spawn, - userConfigOptions: ctx.userConfigOptions, - workspaceDir, - shadowProjectDir, - dryRun - } - - for (const effect of this.inputEffects) { - if (dryRun) { - this.log.trace({action: 'dryRun', type: 'inputEffect', name: effect.name}) - results.push({success: true, description: `Would execute input effect: ${effect.name}`}) - continue - } - - try { - const result = await effect.handler(effectCtx) - if (result.success) { - this.log.trace({action: 'inputEffect', name: effect.name, status: 'success', description: result.description}) - if (result.modifiedFiles != null && result.modifiedFiles.length > 0) { - this.log.debug({action: 'inputEffect', name: effect.name, modifiedFiles: result.modifiedFiles}) - } - if (result.deletedFiles != null && result.deletedFiles.length > 0) { - this.log.debug({action: 'inputEffect', name: effect.name, deletedFiles: result.deletedFiles}) - } - } else { - const errorMsg = result.error instanceof Error ? result.error.message : String(result.error) - this.log.error({action: 'inputEffect', name: effect.name, status: 'failed', error: errorMsg}) - } - results.push(result) - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'inputEffect', name: effect.name, status: 'failed', error: errorMsg}) - results.push({success: false, error: error as Error, description: `Input effect failed: ${effect.name}`}) - } - } - - return results - } - - hasEffects(): boolean { - return this.inputEffects.length > 0 - } - - getEffectCount(): number { - return this.inputEffects.length - } - - protected registerScope(namespace: string, values: Record): void { - this.registeredScopes.push({namespace, values}) - this.log.debug({action: 'registerScope', namespace, keys: Object.keys(values)}) - } - - getRegisteredScopes(): readonly PluginScopeRegistration[] { - return this.registeredScopes - } - - protected clearRegisteredScopes(): void { - this.registeredScopes.length = 0 - this.log.debug({action: 'clearRegisteredScopes'}) - } - - abstract collect(ctx: InputPluginContext): Partial | Promise> - - protected resolveBasePaths(options: Required): ResolvedBasePaths { - const workspaceDirRaw = options.workspaceDir - const workspaceDir = this.resolvePath(workspaceDirRaw, '') - - const shadowProjectName = options.shadowSourceProject.name - const shadowProjectDir = path.join(workspaceDir, shadowProjectName) - - return {workspaceDir, shadowProjectDir} - } - - protected resolvePath(rawPath: string, workspaceDir: string): string { - let resolved = rawPath - - if (resolved.startsWith(PathPlaceholders.USER_HOME)) resolved = resolved.replace(PathPlaceholders.USER_HOME, os.homedir()) - - if (resolved.includes(PathPlaceholders.WORKSPACE)) resolved = resolved.replace(PathPlaceholders.WORKSPACE, workspaceDir) - - return path.normalize(resolved) - } - - protected resolveShadowPath(relativePath: string, shadowProjectDir: string): string { - return path.join(shadowProjectDir, relativePath) - } - - protected readAndParseMarkdown( - filePath: string, - fs: typeof import('node:fs') - ): ParsedMarkdown { - const rawContent = fs.readFileSync(filePath, 'utf8') - return parseMarkdown(rawContent) - } -} diff --git a/packages/plugin-input-shared/src/BaseDirectoryInputPlugin.ts b/packages/plugin-input-shared/src/BaseDirectoryInputPlugin.ts deleted file mode 100644 index 2c468392..00000000 --- a/packages/plugin-input-shared/src/BaseDirectoryInputPlugin.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' -import type { - CollectedInputContext, - InputPluginContext, - PluginOptions, - ResolvedBasePaths, - YAMLFrontMatter -} from '@truenine/plugin-shared' -import {mdxToMd} from '@truenine/md-compiler' -import {MetadataValidationError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {AbstractInputPlugin} from './AbstractInputPlugin' - -/** - * Configuration options for BaseDirectoryInputPlugin - */ -export interface DirectoryInputPluginOptions { - readonly configKey: keyof ResolvedBasePaths | string - - readonly extension?: string -} - -/** - * Abstract base class for input plugins that scan a directory for MDX files. - * Provides common logic for: - * - Directoy scanning - * - File reading - * - MDX compilation - * - Metadata validation - * - Error handling - */ -export abstract class BaseDirectoryInputPlugin< - TPrompt extends { - type: string - content: string - yamlFrontMatter?: TYAML - rawFrontMatter?: string - dir: {path: string, basePath: string} - }, - TYAML extends YAMLFrontMatter -> extends AbstractInputPlugin { - protected readonly configKey: string - protected readonly extension: string - - constructor(name: string, options: DirectoryInputPluginOptions) { - super(name) - this.configKey = options.configKey - this.extension = options.extension ?? '.mdx' - } - - protected abstract validateMetadata(metadata: Record, filePath: string): { - valid: boolean - errors: readonly string[] - warnings: readonly string[] - } - - protected abstract getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string - - protected abstract createPrompt( - entryName: string, - filePath: string, - content: string, - yamlFrontMatter: TYAML | undefined, - rawFrontMatter: string | undefined, - parsed: ParsedMarkdown, - baseDir: string, - rawContent: string - ): TPrompt - - protected abstract createResult(items: TPrompt[]): Partial - - async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger, path, fs, globalScope} = ctx - const resolvedPaths = this.resolveBasePaths(options) - - const targetDir = this.getTargetDir(options, resolvedPaths) - const items: TPrompt[] = [] - - 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.isFile() && entry.name.endsWith(this.extension)) { - const filePath = path.join(targetDir, entry.name) - const rawContent = fs.readFileSync(filePath, 'utf8') - - try { - const parsed = parseMarkdown(rawContent) // Parse YAML front matter first for backward compatibility - - const compileResult = await mdxToMd(rawContent, { // Compile MDX with globalScope and extract metadata from exports - globalScope, - extractMetadata: true, - basePath: targetDir - }) - - const mergedFrontMatter: TYAML | undefined = parsed.yamlFrontMatter != null || Object.keys(compileResult.metadata.fields).length > 0 // Merge YAML front matter with export metadata (export takes priority) - ? { - ...parsed.yamlFrontMatter, - ...compileResult.metadata.fields - } as TYAML - : 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 - - logger.debug(`${this.name} metadata extracted`, { - file: entry.name, - source: compileResult.metadata.source, - hasYaml: parsed.yamlFrontMatter != null, - hasExport: Object.keys(compileResult.metadata.fields).length > 0 - }) - - const prompt = this.createPrompt( - entry.name, - filePath, - content, - mergedFrontMatter, - parsed.rawFrontMatter, - parsed, - targetDir, - rawContent - ) - - items.push(prompt) - } catch (e) { - logger.error(`failed to parse ${this.name} item`, {file: filePath, error: e}) - } - } - } - } catch (e) { - logger.error(`Failed to scan directory at ${targetDir}`, {error: e}) - } - - return this.createResult(items) - } -} diff --git a/packages/plugin-input-shared/src/BaseFileInputPlugin.ts b/packages/plugin-input-shared/src/BaseFileInputPlugin.ts deleted file mode 100644 index c57b3fc8..00000000 --- a/packages/plugin-input-shared/src/BaseFileInputPlugin.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { - CollectedInputContext, - InputPluginContext -} from '@truenine/plugin-shared' -import {AbstractInputPlugin} from './AbstractInputPlugin' - -/** - * Options for configuring BaseFileInputPlugin - */ -export interface FileInputPluginOptions { - readonly fallbackContent?: string -} - -export abstract class BaseFileInputPlugin extends AbstractInputPlugin { - protected readonly options: FileInputPluginOptions - - protected constructor(name: string, options?: FileInputPluginOptions) { - super(name) - this.options = options ?? {} - } - - protected abstract getFilePath(shadowProjectDir: string): string - - protected abstract getResultKey(): keyof CollectedInputContext - - protected transformContent(content: string): TResult { - return content as unknown as TResult - } - - collect(ctx: InputPluginContext): Partial { - const {shadowProjectDir} = this.resolveBasePaths(ctx.userConfigOptions) - const filePath = this.getFilePath(shadowProjectDir) - - if (!ctx.fs.existsSync(filePath)) { - if (this.options.fallbackContent != null) { - this.log.debug({action: 'collect', message: 'Using fallback content', path: filePath}) - return {[this.getResultKey()]: this.transformContent(this.options.fallbackContent)} as Partial - } - this.log.debug({action: 'collect', message: 'File not found', path: filePath}) - return {} - } - - const content = ctx.fs.readFileSync(filePath, 'utf8') - - if (content.length === 0) { - if (this.options.fallbackContent != null) { - this.log.debug({action: 'collect', message: 'File empty, using fallback', path: filePath}) - return {[this.getResultKey()]: this.transformContent(this.options.fallbackContent)} as Partial - } - this.log.debug({action: 'collect', message: 'File is empty', path: filePath}) - return {} - } - - this.log.debug({action: 'collect', message: 'Loaded file content', path: filePath, length: content.length}) - return {[this.getResultKey()]: this.transformContent(content)} as Partial - } -} diff --git a/packages/plugin-input-shared/src/index.ts b/packages/plugin-input-shared/src/index.ts deleted file mode 100644 index 0941c3dd..00000000 --- a/packages/plugin-input-shared/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { - AbstractInputPlugin -} from './AbstractInputPlugin' -export { - BaseDirectoryInputPlugin -} from './BaseDirectoryInputPlugin' -export type { - DirectoryInputPluginOptions -} from './BaseDirectoryInputPlugin' -export { - BaseFileInputPlugin -} from './BaseFileInputPlugin' -export type { - FileInputPluginOptions -} from './BaseFileInputPlugin' diff --git a/packages/plugin-input-shared/src/scope/GlobalScopeCollector.ts b/packages/plugin-input-shared/src/scope/GlobalScopeCollector.ts deleted file mode 100644 index 2a2c06ed..00000000 --- a/packages/plugin-input-shared/src/scope/GlobalScopeCollector.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type {EnvironmentContext, MdComponent, MdxGlobalScope, OsInfo, ToolReferences, UserProfile} from '@truenine/md-compiler/globals' // Collects and manages global scope variables for MDX expression evaluation. // src/scope/GlobalScopeCollector.ts -import type {UserConfigFile} from '@truenine/plugin-shared' -import * as os from 'node:os' -import process from 'node:process' -import {OsKind, ShellKind, ToolPresets} from '@truenine/md-compiler/globals' - -/** - * Tool preset names supported by GlobalScopeCollector - */ -export type ToolPresetName = keyof typeof ToolPresets - -/** - * Options for GlobalScopeCollector - */ -export interface GlobalScopeCollectorOptions { - /** User configuration file */ - readonly userConfig?: UserConfigFile | undefined - /** Tool preset to use (default: 'default') */ - readonly toolPreset?: ToolPresetName | undefined -} - -/** - * Collects global scope variables from system, environment, and user configuration. - * The collected scope is available in MDX templates via expressions like {os.platform}, {env.NODE_ENV}, etc. - */ -export class GlobalScopeCollector { - private readonly userConfig: UserConfigFile | undefined - private readonly toolPreset: ToolPresetName - - constructor(options: GlobalScopeCollectorOptions = {}) { - this.userConfig = options.userConfig - this.toolPreset = options.toolPreset ?? 'default' - } - - collect(): MdxGlobalScope { - return { - os: this.collectOsInfo(), - env: this.collectEnvContext(), - profile: this.collectProfile(), - tool: this.collectToolReferences(), - Md: this.createMdComponent() - } - } - - private collectOsInfo(): OsInfo { - const platform = os.platform() - return { - platform, - arch: os.arch(), - hostname: os.hostname(), - homedir: os.homedir(), - tmpdir: os.tmpdir(), - type: os.type(), - release: os.release(), - shellKind: this.detectShellKind(), - kind: this.detectOsKind(platform) - } - } - - private detectOsKind(platform: string): OsKind { - switch (platform) { - case 'win32': return OsKind.Win - case 'darwin': return OsKind.Mac - case 'linux': - case 'freebsd': - case 'openbsd': - case 'sunos': - case 'aix': return OsKind.Linux - default: return OsKind.Unknown - } - } - - private detectShellKind(): ShellKind { - const shell = process.env['SHELL'] ?? process.env['ComSpec'] ?? '' - const s = shell.toLowerCase() - - if (s.includes('bash')) return ShellKind.Bash - if (s.includes('zsh')) return ShellKind.Zsh - if (s.includes('fish')) return ShellKind.Fish - if (s.includes('pwsh')) return ShellKind.Pwsh - if (s.includes('powershell')) return ShellKind.PowerShell - if (s.includes('cmd')) return ShellKind.Cmd - if (s.endsWith('/sh')) return ShellKind.Sh - - return ShellKind.Unknown - } - - private collectEnvContext(): EnvironmentContext { - return {...process.env} - } - - private collectProfile(): UserProfile { - if (this.userConfig?.profile != null) return this.userConfig.profile as UserProfile - return {} - } - - private collectToolReferences(): ToolReferences { - const defaults: ToolReferences = {...ToolPresets.default} - if (this.toolPreset === 'claudeCode') return {...defaults, ...ToolPresets.claudeCode} - if (this.toolPreset === 'kiro') return {...defaults, ...ToolPresets.kiro} - return defaults - } - - private createMdComponent(): MdComponent { - const mdComponent = ((props: {when?: boolean, children?: unknown}) => { - if (props.when === false) return null - return props.children - }) as MdComponent - - mdComponent.Line = (props: {when?: boolean, children?: unknown}) => { - if (props.when === false) return null - return props.children - } - - return mdComponent - } -} diff --git a/packages/plugin-input-shared/src/scope/ScopeRegistry.ts b/packages/plugin-input-shared/src/scope/ScopeRegistry.ts deleted file mode 100644 index 45e5e951..00000000 --- a/packages/plugin-input-shared/src/scope/ScopeRegistry.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type {EvaluationScope} from '@truenine/md-compiler' // Manages scope registration and merging with priority-based resolution. // src/scope/ScopeRegistry.ts -import type {MdxGlobalScope} from '@truenine/md-compiler/globals' - -/** - * Represents a single scope registration - */ -export interface ScopeRegistration { - readonly namespace: string - readonly values: Record - readonly priority: number -} - -/** - * Priority levels for scope sources. - * Higher values take precedence over lower values during merge. - */ -export enum ScopePriority { - /** System default values (os, default tool) */ - SystemDefault = 0, - /** Values from configuration file (profile, custom tool) */ - UserConfig = 10, - /** Values registered by plugins */ - PluginRegistered = 20, - /** Values passed at MDX compile time */ - CompileTime = 30 -} - -/** - * Registry for managing and merging scopes from multiple sources. - * Handles priority-based resolution when the same key exists in multiple sources. - */ -export class ScopeRegistry { - private readonly registrations: ScopeRegistration[] = [] - private globalScope: MdxGlobalScope | null = null - - setGlobalScope(scope: MdxGlobalScope): void { - this.globalScope = scope - } - - getGlobalScope(): MdxGlobalScope | null { - return this.globalScope - } - - register( - namespace: string, - values: Record, - priority: ScopePriority = ScopePriority.PluginRegistered - ): void { - this.registrations.push({namespace, values, priority}) - } - - getRegistrations(): readonly ScopeRegistration[] { - return this.registrations - } - - merge(compileTimeScope?: EvaluationScope): EvaluationScope { - const result: EvaluationScope = {} - - if (this.globalScope != null) { // 1. First add global scope (lowest priority) - result['os'] = {...this.globalScope.os} - result['env'] = {...this.globalScope.env} - result['profile'] = {...this.globalScope.profile} - result['tool'] = {...this.globalScope.tool} - } - - const sorted = [...this.registrations].sort((a, b) => a.priority - b.priority) // 2. Sort by priority and merge registered scopes - for (const reg of sorted) result[reg.namespace] = this.deepMerge(result[reg.namespace] as Record | undefined, reg.values) - - if (compileTimeScope != null) { // 3. Finally merge compile-time scope (highest priority) - for (const [key, value] of Object.entries(compileTimeScope)) { - result[key] = typeof value === 'object' && value !== null && !Array.isArray(value) - ? this.deepMerge(result[key] as Record | undefined, value as Record) - : value - } - } - - return result - } - - private deepMerge( - target: Record | undefined, - source: Record - ): Record { - if (target == null) return {...source} - - const result = {...target} - for (const [key, value] of Object.entries(source)) { - result[key] = typeof value === 'object' - && value !== null - && !Array.isArray(value) - && typeof result[key] === 'object' - && result[key] !== null - && !Array.isArray(result[key]) - ? this.deepMerge(result[key] as Record, value as Record) - : value - } - return result - } - - resolve(expression: string): string { - const scope = this.merge() - return expression.replaceAll(/\$\{([^}]+)\}/g, (_, key: string) => { - const parts = key.split('.') - let value: unknown = scope - for (const part of parts) value = (value as Record)?.[part] - return value != null ? String(value) : `\${${key}}` - }) - } - - clear(): void { - this.registrations.length = 0 - this.globalScope = null - } -} diff --git a/packages/plugin-input-shared/src/scope/index.ts b/packages/plugin-input-shared/src/scope/index.ts deleted file mode 100644 index be015465..00000000 --- a/packages/plugin-input-shared/src/scope/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { // Public API exports for the scope management module. // src/scope/index.ts - GlobalScopeCollector -} from './GlobalScopeCollector' -export type { - GlobalScopeCollectorOptions -} from './GlobalScopeCollector' - -export { - ScopePriority, - ScopeRegistry -} from './ScopeRegistry' -export type { - ScopeRegistration -} from './ScopeRegistry' diff --git a/packages/plugin-input-shared/tsconfig.eslint.json b/packages/plugin-input-shared/tsconfig.eslint.json deleted file mode 100644 index 585b38ee..00000000 --- a/packages/plugin-input-shared/tsconfig.eslint.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "skipLibCheck": true - }, - "include": [ - "src/**/*.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "env.d.ts", - "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" - ], - "exclude": [ - "../node_modules", - "dist", - "coverage" - ] -} diff --git a/packages/plugin-input-shared/tsconfig.json b/packages/plugin-input-shared/tsconfig.json deleted file mode 100644 index 03cd50a3..00000000 --- a/packages/plugin-input-shared/tsconfig.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": [ - "ESNext" - ], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { - "@/*": [ - "./src/*" - ] - }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": [ - "src/**/*", - "env.d.ts", - "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" - ], - "exclude": [ - "../node_modules", - "dist" - ] -} diff --git a/packages/plugin-input-shared/tsconfig.lib.json b/packages/plugin-input-shared/tsconfig.lib.json deleted file mode 100644 index b2449b37..00000000 --- a/packages/plugin-input-shared/tsconfig.lib.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "noEmit": false, - "outDir": "../dist", - "skipLibCheck": true - }, - "include": [ - "src/**/*", - "env.d.ts" - ], - "exclude": [ - "../node_modules", - "dist", - "**/*.spec.ts", - "**/*.test.ts" - ] -} diff --git a/packages/plugin-input-shared/tsconfig.test.json b/packages/plugin-input-shared/tsconfig.test.json deleted file mode 100644 index 65c3c9ad..00000000 --- a/packages/plugin-input-shared/tsconfig.test.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "lib": [ - "ESNext", - "DOM" - ], - "types": [ - "vitest/globals", - "node" - ] - }, - "include": [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "vitest.config.ts", - "vite.config.ts", - "env.d.ts" - ], - "exclude": [ - "../node_modules", - "dist" - ] -} diff --git a/packages/plugin-input-shared/tsdown.config.ts b/packages/plugin-input-shared/tsdown.config.ts deleted file mode 100644 index 2261026f..00000000 --- a/packages/plugin-input-shared/tsdown.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: [ - './src/index.ts', - './src/scope/index.ts', - '!**/*.{spec,test}.*' - ], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: { - '@': resolve('src') - }, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-shared/vite.config.ts b/packages/plugin-input-shared/vite.config.ts deleted file mode 100644 index 2dcc5646..00000000 --- a/packages/plugin-input-shared/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - } - } -}) diff --git a/packages/plugin-input-shared/vitest.config.ts b/packages/plugin-input-shared/vitest.config.ts deleted file mode 100644 index a06eb3a7..00000000 --- a/packages/plugin-input-shared/vitest.config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {fileURLToPath} from 'node:url' - -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' - -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: { - enabled: true, - tsconfig: './tsconfig.test.json' - }, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/', - 'dist/', - '**/*.test.ts', - '**/*.property.test.ts' - ] - } - } - }) -) diff --git a/packages/plugin-input-skill-sync-effect/eslint.config.ts b/packages/plugin-input-skill-sync-effect/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-skill-sync-effect/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-skill-sync-effect/package.json b/packages/plugin-input-skill-sync-effect/package.json deleted file mode 100644 index f5ea92ed..00000000 --- a/packages/plugin-input-skill-sync-effect/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-skill-sync-effect", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-skill-sync-effect for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "fast-glob": "catalog:" - } -} diff --git a/packages/plugin-input-skill-sync-effect/src/SkillNonSrcFileSyncEffectInputPlugin.property.test.ts b/packages/plugin-input-skill-sync-effect/src/SkillNonSrcFileSyncEffectInputPlugin.property.test.ts deleted file mode 100644 index 0ec8b9f2..00000000 --- a/packages/plugin-input-skill-sync-effect/src/SkillNonSrcFileSyncEffectInputPlugin.property.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type {InputEffectContext} from '@truenine/plugin-input-shared' -import type {ILogger, PluginOptions} from '@truenine/plugin-shared' - -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import * as fc from 'fast-check' -import * as glob from 'fast-glob' -import {describe, expect, it} from 'vitest' -import {SkillNonSrcFileSyncEffectInputPlugin} from './SkillNonSrcFileSyncEffectInputPlugin' - -/** - * Feature: effect-input-plugins - * Property-based tests for SkillNonSrcFileSyncEffectInputPlugin - * - * Property 1: Non-.cn.mdx file sync correctness - * For any file in src/skills/{skill_name}/ that does not end with .cn.mdx, - * after the plugin executes, the file should exist at dist/skills/{skill_name}/{relative_path} - * with identical content. - * - * Property 3: Identical content skip (Idempotence) - * For any file that already exists at the destination with identical content to the source, - * running the plugin should not modify the destination file. - * - * Validates: Requirements 1.2, 1.4 - */ - -function createMockLogger(): ILogger { // Test helpers - return { - trace: () => { }, - debug: () => { }, - info: () => { }, - warn: () => { }, - error: () => { }, - fatal: () => { }, - child: () => createMockLogger() - } as unknown as ILogger -} - -function createEffectContext(workspaceDir: string, shadowProjectDir: string, dryRun: boolean = false): InputEffectContext { - return { - logger: createMockLogger(), - fs, - path, - glob, - userConfigOptions: {} as PluginOptions, - workspaceDir, - shadowProjectDir, - dryRun - } -} - -const validFileNameGen = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generators - .filter(s => /^[\w-]+$/.test(s)) - .map(s => s.toLowerCase()) - -const fileExtensionGen = fc.constantFrom('.ts', '.js', '.json', '.sh', '.txt', '.md', '.yaml', '.yml') - -const fileContentGen = fc.string({minLength: 0, maxLength: 1000}) - -const nonSrcMdFileNameGen = fc.tuple(validFileNameGen, fileExtensionGen) // Generate a non-.cn.mdx filename - .map(([name, ext]) => `${name}${ext}`) - .filter(name => !name.endsWith('.cn.mdx')) - -const srcMdFileNameGen = validFileNameGen.map(name => `${name}.cn.mdx`) // Generate a .cn.mdx filename - -interface SkillFile { // Generate skill directory structure - relativePath: string - content: string - isSrcMd: boolean -} - -interface SkillStructure { - skillName: string - files: SkillFile[] -} - -const skillStructureGen: fc.Arbitrary = fc.record({ - skillName: validFileNameGen, - files: fc.array( - fc.oneof( - fc.record({ // Non-.cn.mdx files (should be synced) - relativePath: nonSrcMdFileNameGen, - content: fileContentGen, - isSrcMd: fc.constant(false) - }), - fc.record({ // .cn.mdx files (should NOT be synced) - relativePath: srcMdFileNameGen, - content: fileContentGen, - isSrcMd: fc.constant(true) - }) - ), - {minLength: 1, maxLength: 5} - ) -}).map(skill => { - const seen = new Set() // Deduplicate files by relativePath, keeping the first occurrence - const uniqueFiles = skill.files.filter(file => { - if (seen.has(file.relativePath)) return false - seen.add(file.relativePath) - return true - }) - return {...skill, files: uniqueFiles} -}).filter(skill => skill.files.length > 0) - -describe('skillNonSrcFileSyncEffectInputPlugin Property Tests', () => { - describe('property 1: Non-.cn.mdx file sync correctness', () => { - it('should sync all non-.cn.mdx files from src/skills/ to dist/skills/ with identical content', {timeout: 60000}, async () => { - await fc.assert( - fc.asyncProperty( - fc.array(skillStructureGen, {minLength: 1, maxLength: 3}), - async skills => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-sync-p1-')) // Create isolated temp directory for this property run - - try { - const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create src/skills/ structure - const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') - const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') - - for (const skill of skills) { // Create skill directories and files - const skillDir = path.join(srcSkillsDir, skill.skillName) - fs.mkdirSync(skillDir, {recursive: true}) - - for (const file of skill.files) { - const filePath = path.join(skillDir, file.relativePath) - fs.mkdirSync(path.dirname(filePath), {recursive: true}) - fs.writeFileSync(filePath, file.content, 'utf8') - } - } - - const plugin = new SkillNonSrcFileSyncEffectInputPlugin() // Execute plugin - const ctx = createEffectContext(tempDir, shadowProjectDir, false) - const effectMethod = (plugin as any).syncNonSrcFiles.bind(plugin) - await effectMethod(ctx) - - for (const skill of skills) { // Verify: All non-.cn.mdx files should exist in dist with identical content - for (const file of skill.files) { - const distPath = path.join(distSkillsDir, skill.skillName, file.relativePath) - - if (file.isSrcMd) { - expect(fs.existsSync(distPath)).toBe(false) // .cn.mdx files should NOT be synced - } else { - expect(fs.existsSync(distPath)).toBe(true) // Non-.cn.mdx files should be synced with identical content - const distContent = fs.readFileSync(distPath, 'utf8') - expect(distContent).toBe(file.content) - } - } - } - } - finally { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup - } - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 3: Identical content skip (Idempotence)', () => { - it('should skip files with identical content and not modify them', async () => { - await fc.assert( - fc.asyncProperty( - skillStructureGen.filter(s => s.files.some(f => !f.isSrcMd)), - async skill => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-sync-p3a-')) // Create isolated temp directory for this property run - - try { - const shadowProjectDir = path.join(tempDir, 'shadow') // Setup: Create src/skills/ and dist/skills/ with identical files - const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') - const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') - - const skillSrcDir = path.join(srcSkillsDir, skill.skillName) - const skillDistDir = path.join(distSkillsDir, skill.skillName) - - fs.mkdirSync(skillSrcDir, {recursive: true}) - fs.mkdirSync(skillDistDir, {recursive: true}) - - const nonSrcMdFiles = skill.files.filter(f => !f.isSrcMd) - - for (const file of nonSrcMdFiles) { // Create source files and pre-existing dist files with identical content - const srcPath = path.join(skillSrcDir, file.relativePath) - const distPath = path.join(skillDistDir, file.relativePath) - - fs.mkdirSync(path.dirname(srcPath), {recursive: true}) - fs.mkdirSync(path.dirname(distPath), {recursive: true}) - - fs.writeFileSync(srcPath, file.content, 'utf8') - fs.writeFileSync(distPath, file.content, 'utf8') - } - - const plugin = new SkillNonSrcFileSyncEffectInputPlugin() // Execute plugin - const ctx = createEffectContext(tempDir, shadowProjectDir, false) - const effectMethod = (plugin as any).syncNonSrcFiles.bind(plugin) - const result = await effectMethod(ctx) - - for (const file of nonSrcMdFiles) { // Verify: Files with identical content should be in skippedFiles - const distPath = path.join(skillDistDir, file.relativePath) - expect(result.skippedFiles).toContain(distPath) - expect(result.copiedFiles).not.toContain(distPath) - } - } - finally { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup - } - } - ), - {numRuns: 100} - ) - }) - - it('should be idempotent - running twice produces same result', async () => { - await fc.assert( - fc.asyncProperty( - skillStructureGen.filter(s => s.files.some(f => !f.isSrcMd)), - async skill => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-sync-p3b-')) // Create isolated temp directory for this property run - - try { - const shadowProjectDir = path.join(tempDir, 'shadow') // Setup - const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') - const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') - - const skillSrcDir = path.join(srcSkillsDir, skill.skillName) - fs.mkdirSync(skillSrcDir, {recursive: true}) - - for (const file of skill.files) { - const srcPath = path.join(skillSrcDir, file.relativePath) - fs.mkdirSync(path.dirname(srcPath), {recursive: true}) - fs.writeFileSync(srcPath, file.content, 'utf8') - } - - const plugin = new SkillNonSrcFileSyncEffectInputPlugin() // Execute plugin first time - const ctx = createEffectContext(tempDir, shadowProjectDir, false) - const effectMethod = (plugin as any).syncNonSrcFiles.bind(plugin) - await effectMethod(ctx) - - const result2 = await effectMethod(ctx) // Execute plugin second time - - const nonSrcMdFiles = skill.files.filter(f => !f.isSrcMd) // Verify: Second run should skip all files (idempotence) - expect(result2.copiedFiles.length).toBe(0) - expect(result2.skippedFiles.length).toBe(nonSrcMdFiles.length) - - for (const file of nonSrcMdFiles) { // Verify content is still identical - const srcPath = path.join(skillSrcDir, file.relativePath) - const distPath = path.join(distSkillsDir, skill.skillName, file.relativePath) - - const srcContent = fs.readFileSync(srcPath, 'utf8') - const distContent = fs.readFileSync(distPath, 'utf8') - expect(distContent).toBe(srcContent) - } - } - finally { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Cleanup - } - } - ), - {numRuns: 100} - ) - }) - }) -}) diff --git a/packages/plugin-input-skill-sync-effect/src/SkillNonSrcFileSyncEffectInputPlugin.ts b/packages/plugin-input-skill-sync-effect/src/SkillNonSrcFileSyncEffectInputPlugin.ts deleted file mode 100644 index c78a5a12..00000000 --- a/packages/plugin-input-skill-sync-effect/src/SkillNonSrcFileSyncEffectInputPlugin.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type {CollectedInputContext, InputEffectContext, InputEffectResult, InputPluginContext} from '@truenine/plugin-shared' - -import type {Buffer} from 'node:buffer' -import {createHash} from 'node:crypto' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' - -/** - * Result of the skill non-.cn.mdx file sync effect. - */ -export interface SkillSyncEffectResult extends InputEffectResult { - readonly copiedFiles: string[] - readonly skippedFiles: string[] - readonly createdDirs: string[] -} - -export class SkillNonSrcFileSyncEffectInputPlugin extends AbstractInputPlugin { - constructor() { - super('SkillNonSrcFileSyncEffectInputPlugin') - this.registerEffect('skill-non-src-file-sync', this.syncNonSrcFiles.bind(this), 10) - } - - private async syncNonSrcFiles(ctx: InputEffectContext): Promise { - const {fs, path, shadowProjectDir, dryRun, logger} = ctx - - const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') - const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') - - const copiedFiles: string[] = [] - const skippedFiles: string[] = [] - const createdDirs: string[] = [] - const errors: {path: string, error: Error}[] = [] - - if (!fs.existsSync(srcSkillsDir)) { - logger.debug({action: 'skill-sync', message: 'src/skills/ directory does not exist, skipping', srcSkillsDir}) - return { - success: true, - description: 'src/skills/ directory does not exist, nothing to sync', - copiedFiles, - skippedFiles, - createdDirs - } - } - - this.syncDirectoryRecursive( - ctx, - srcSkillsDir, - distSkillsDir, - '', - copiedFiles, - skippedFiles, - createdDirs, - errors, - dryRun ?? false - ) - - const hasErrors = errors.length > 0 - if (hasErrors) logger.warn({action: 'skill-sync', errors: errors.map(e => ({path: e.path, error: e.error.message}))}) - - return { - success: !hasErrors, - description: dryRun - ? `Would copy ${copiedFiles.length} files, skip ${skippedFiles.length} files` - : `Copied ${copiedFiles.length} files, skipped ${skippedFiles.length} files`, - copiedFiles, - skippedFiles, - createdDirs, - ...hasErrors && {error: new Error(`${errors.length} errors occurred during sync`)}, - modifiedFiles: copiedFiles - } - } - - private syncDirectoryRecursive( - ctx: InputEffectContext, - srcDir: string, - distDir: string, - relativePath: string, - copiedFiles: string[], - skippedFiles: string[], - createdDirs: string[], - errors: {path: string, error: Error}[], - dryRun: boolean - ): void { - const {fs, path, logger} = ctx - - const currentSrcDir = relativePath ? path.join(srcDir, relativePath) : srcDir - - if (!fs.existsSync(currentSrcDir)) return - - let entries: import('node:fs').Dirent[] - try { - entries = fs.readdirSync(currentSrcDir, {withFileTypes: true}) - } - catch (error) { - errors.push({path: currentSrcDir, error: error as Error}) - logger.warn({action: 'skill-sync', message: 'Failed to read directory', path: currentSrcDir, error: (error as Error).message}) - return - } - - for (const entry of entries) { - const entryRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name - const srcPath = path.join(srcDir, entryRelativePath) - const distPath = path.join(distDir, entryRelativePath) - - if (entry.isDirectory()) { - this.syncDirectoryRecursive( - ctx, - srcDir, - distDir, - entryRelativePath, - copiedFiles, - skippedFiles, - createdDirs, - errors, - dryRun - ) - } else if (entry.isFile()) { - if (entry.name.endsWith('.cn.mdx')) continue - - const targetDir = path.dirname(distPath) - if (!fs.existsSync(targetDir)) { - if (dryRun) { - logger.debug({action: 'skill-sync', dryRun: true, wouldCreateDir: targetDir}) - createdDirs.push(targetDir) - } else { - try { - fs.mkdirSync(targetDir, {recursive: true}) - createdDirs.push(targetDir) - logger.debug({action: 'skill-sync', createdDir: targetDir}) - } - catch (error) { - errors.push({path: targetDir, error: error as Error}) - logger.warn({action: 'skill-sync', message: 'Failed to create directory', path: targetDir, error: (error as Error).message}) - continue - } - } - } - - if (fs.existsSync(distPath)) { - try { - const srcContent = fs.readFileSync(srcPath) - const distContent = fs.readFileSync(distPath) - - const srcHash = this.computeHash(srcContent) - const distHash = this.computeHash(distContent) - - if (srcHash === distHash) { - skippedFiles.push(distPath) - logger.debug({action: 'skill-sync', skipped: distPath, reason: 'identical content'}) - continue - } - } - catch (error) { - logger.debug({action: 'skill-sync', message: 'Could not compare files, will copy', path: distPath, error: (error as Error).message}) - } - } - - if (dryRun) { - logger.debug({action: 'skill-sync', dryRun: true, wouldCopy: {from: srcPath, to: distPath}}) - copiedFiles.push(distPath) - } else { - try { - fs.copyFileSync(srcPath, distPath) - copiedFiles.push(distPath) - logger.debug({action: 'skill-sync', copied: {from: srcPath, to: distPath}}) - } - catch (error) { - errors.push({path: distPath, error: error as Error}) - logger.warn({action: 'skill-sync', message: 'Failed to copy file', from: srcPath, to: distPath, error: (error as Error).message}) - } - } - } - } - } - - private computeHash(content: Buffer): string { - return createHash('sha256').update(content).digest('hex') - } - - collect(_ctx: InputPluginContext): Partial { - return {} - } -} diff --git a/packages/plugin-input-skill-sync-effect/src/index.ts b/packages/plugin-input-skill-sync-effect/src/index.ts deleted file mode 100644 index 7b4c1a24..00000000 --- a/packages/plugin-input-skill-sync-effect/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - SkillNonSrcFileSyncEffectInputPlugin -} from './SkillNonSrcFileSyncEffectInputPlugin' -export type { - SkillSyncEffectResult -} from './SkillNonSrcFileSyncEffectInputPlugin' diff --git a/packages/plugin-input-skill-sync-effect/tsconfig.eslint.json b/packages/plugin-input-skill-sync-effect/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-skill-sync-effect/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-skill-sync-effect/tsconfig.json b/packages/plugin-input-skill-sync-effect/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-skill-sync-effect/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-skill-sync-effect/tsconfig.lib.json b/packages/plugin-input-skill-sync-effect/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-skill-sync-effect/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-skill-sync-effect/tsconfig.test.json b/packages/plugin-input-skill-sync-effect/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-skill-sync-effect/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-skill-sync-effect/tsdown.config.ts b/packages/plugin-input-skill-sync-effect/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-skill-sync-effect/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-skill-sync-effect/vite.config.ts b/packages/plugin-input-skill-sync-effect/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-skill-sync-effect/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-skill-sync-effect/vitest.config.ts b/packages/plugin-input-skill-sync-effect/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-skill-sync-effect/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-subagent/eslint.config.ts b/packages/plugin-input-subagent/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-subagent/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-subagent/package.json b/packages/plugin-input-subagent/package.json deleted file mode 100644 index ae4b9c74..00000000 --- a/packages/plugin-input-subagent/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-input-subagent", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-subagent for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-subagent/src/SubAgentInputPlugin.test.ts b/packages/plugin-input-subagent/src/SubAgentInputPlugin.test.ts deleted file mode 100644 index 9873347b..00000000 --- a/packages/plugin-input-subagent/src/SubAgentInputPlugin.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' -import {SubAgentInputPlugin} from './SubAgentInputPlugin' - -describe('subAgentInputPlugin', () => { - describe('extractSeriesInfo', () => { - const plugin = new SubAgentInputPlugin() - - it('should derive series from parentDirName when provided', () => { - const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z0-9]+$/i.test(s)) - - const alphanumericAgentName = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s)) - - fc.assert( - fc.property( - alphanumericNoUnderscore, - alphanumericAgentName, - (parentDir, agentName) => { - const fileName = `${agentName}.mdx` - const result = plugin.extractSeriesInfo(fileName, parentDir) - - expect(result.series).toBe(parentDir) - expect(result.agentName).toBe(agentName) - } - ), - {numRuns: 100} - ) - }) - - it('should handle explore/deep.cn.mdx subdirectory format', () => { - const result = plugin.extractSeriesInfo('deep.cn.mdx', 'explore') - expect(result.series).toBe('explore') - expect(result.agentName).toBe('deep.cn') - }) - - it('should handle context/gatherer.cn.mdx subdirectory format', () => { - const result = plugin.extractSeriesInfo('gatherer.cn.mdx', 'context') - expect(result.series).toBe('context') - expect(result.agentName).toBe('gatherer.cn') - }) - - it('should extract series as substring before first underscore for filenames with underscore', () => { - const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z0-9]+$/i.test(s)) - - const alphanumericWithUnderscore = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^\w+$/.test(s)) - - fc.assert( - fc.property( - alphanumericNoUnderscore, - alphanumericWithUnderscore, - (seriesPrefix, agentName) => { - const fileName = `${seriesPrefix}_${agentName}.mdx` - const result = plugin.extractSeriesInfo(fileName) - - expect(result.series).toBe(seriesPrefix) - expect(result.agentName).toBe(agentName) - } - ), - {numRuns: 100} - ) - }) - - it('should return undefined series for filenames without underscore', () => { - const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z0-9]+$/i.test(s)) - - fc.assert( - fc.property( - alphanumericNoUnderscore, - baseName => { - const fileName = `${baseName}.mdx` - const result = plugin.extractSeriesInfo(fileName) - - expect(result.series).toBeUndefined() - expect(result.agentName).toBe(baseName) - } - ), - {numRuns: 100} - ) - }) - - it('should use only first underscore as delimiter', () => { - const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z0-9]+$/i.test(s)) - - fc.assert( - fc.property( - alphanumericNoUnderscore, - alphanumericNoUnderscore, - alphanumericNoUnderscore, - (seriesPrefix, part1, part2) => { - const fileName = `${seriesPrefix}_${part1}_${part2}.mdx` - const result = plugin.extractSeriesInfo(fileName) - - expect(result.series).toBe(seriesPrefix) - expect(result.agentName).toBe(`${part1}_${part2}`) - } - ), - {numRuns: 100} - ) - }) - - it('should handle explore_deep.mdx correctly', () => { - const result = plugin.extractSeriesInfo('explore_deep.mdx') - expect(result.series).toBe('explore') - expect(result.agentName).toBe('deep') - }) - - it('should handle simple.mdx correctly (no underscore)', () => { - const result = plugin.extractSeriesInfo('simple.mdx') - expect(result.series).toBeUndefined() - expect(result.agentName).toBe('simple') - }) - - it('should handle explore_deep_search.mdx correctly (multiple underscores)', () => { - const result = plugin.extractSeriesInfo('explore_deep_search.mdx') - expect(result.series).toBe('explore') - expect(result.agentName).toBe('deep_search') - }) - - it('should handle _agent.mdx correctly (empty prefix)', () => { - const result = plugin.extractSeriesInfo('_agent.mdx') - expect(result.series).toBe('') - expect(result.agentName).toBe('agent') - }) - - it('should prioritize parentDirName over underscore naming', () => { - const result = plugin.extractSeriesInfo('explore_deep.mdx', 'context') - expect(result.series).toBe('context') - expect(result.agentName).toBe('explore_deep') - }) - }) -}) diff --git a/packages/plugin-input-subagent/src/SubAgentInputPlugin.ts b/packages/plugin-input-subagent/src/SubAgentInputPlugin.ts deleted file mode 100644 index 132391b8..00000000 --- a/packages/plugin-input-subagent/src/SubAgentInputPlugin.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' -import type { - CollectedInputContext, - InputPluginContext, - MetadataValidationResult, - PluginOptions, - ResolvedBasePaths, - SubAgentPrompt, - SubAgentYAMLFrontMatter -} from '@truenine/plugin-shared' -import {mdxToMd} from '@truenine/md-compiler' -import {MetadataValidationError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {BaseDirectoryInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind, - PromptKind, - validateSubAgentMetadata -} from '@truenine/plugin-shared' - -export interface SubAgentSeriesInfo { - readonly series?: string - readonly agentName: string -} - -export class SubAgentInputPlugin extends BaseDirectoryInputPlugin { - constructor() { - super('SubAgentInputPlugin', {configKey: 'shadowSourceProject.subAgent.dist'}) - } - - protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { - return this.resolveShadowPath(options.shadowSourceProject.subAgent.dist, resolvedPaths.shadowProjectDir) - } - - protected validateMetadata(metadata: Record, filePath: string): MetadataValidationResult { - return validateSubAgentMetadata(metadata, filePath) - } - - protected createResult(items: SubAgentPrompt[]): Partial { - return {subAgents: items} - } - - extractSeriesInfo(fileName: string, parentDirName?: string): SubAgentSeriesInfo { - const baseName = fileName.replace(/\.mdx$/, '') - - if (parentDirName != null) { - return { - series: parentDirName, - agentName: baseName - } - } - - const underscoreIndex = baseName.indexOf('_') - - if (underscoreIndex === -1) return {agentName: baseName} - - return { - series: baseName.slice(0, Math.max(0, underscoreIndex)), - agentName: baseName.slice(Math.max(0, underscoreIndex + 1)) - } - } - - 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: SubAgentPrompt[] = [] - - 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.isFile() && entry.name.endsWith(this.extension)) { - const prompt = await this.processFile(entry.name, path.join(targetDir, entry.name), targetDir, void 0, ctx) - if (prompt != null) items.push(prompt) - } else 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 | undefined, - 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: parentDirName != null ? ctx.path.join(baseDir, parentDirName) : baseDir - }) - - const mergedFrontMatter: SubAgentYAMLFrontMatter | undefined = parsed.yamlFrontMatter != null || Object.keys(compileResult.metadata.fields).length > 0 - ? { - ...parsed.yamlFrontMatter, - ...compileResult.metadata.fields - } as SubAgentYAMLFrontMatter - : 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 != null ? `${parentDirName}/${fileName}` : 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 - } - } - - protected createPrompt( - entryName: string, - filePath: string, - content: string, - yamlFrontMatter: SubAgentYAMLFrontMatter | undefined, - rawFrontMatter: string | undefined, - parsed: ParsedMarkdown, - baseDir: string, - rawContent: string - ): SubAgentPrompt { - const slashIndex = entryName.indexOf('/') - const parentDirName = slashIndex !== -1 ? entryName.slice(0, slashIndex) : void 0 - const fileName = slashIndex !== -1 ? entryName.slice(slashIndex + 1) : entryName - - const seriesInfo = this.extractSeriesInfo(fileName, parentDirName) - const seriName = yamlFrontMatter?.seriName - - return { - type: PromptKind.SubAgent, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - ...yamlFrontMatter != null && {yamlFrontMatter}, - ...rawFrontMatter != null && {rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - dir: { - pathKind: FilePathKind.Relative, - path: entryName, - basePath: baseDir, - getDirectoryName: () => entryName.replace(/\.mdx$/, ''), - getAbsolutePath: () => filePath - }, - ...seriesInfo.series != null && {series: seriesInfo.series}, - agentName: seriesInfo.agentName, - ...seriName != null && {seriName}, - rawMdxContent: rawContent - } - } -} diff --git a/packages/plugin-input-subagent/src/index.ts b/packages/plugin-input-subagent/src/index.ts deleted file mode 100644 index 055e0454..00000000 --- a/packages/plugin-input-subagent/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - SubAgentInputPlugin -} from './SubAgentInputPlugin' -export type { - SubAgentSeriesInfo -} from './SubAgentInputPlugin' diff --git a/packages/plugin-input-subagent/tsconfig.eslint.json b/packages/plugin-input-subagent/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-subagent/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-subagent/tsconfig.json b/packages/plugin-input-subagent/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-subagent/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-subagent/tsconfig.lib.json b/packages/plugin-input-subagent/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-subagent/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-subagent/tsconfig.test.json b/packages/plugin-input-subagent/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-subagent/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-subagent/tsdown.config.ts b/packages/plugin-input-subagent/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-subagent/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-subagent/vite.config.ts b/packages/plugin-input-subagent/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-subagent/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-subagent/vitest.config.ts b/packages/plugin-input-subagent/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-subagent/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-vscode-config/eslint.config.ts b/packages/plugin-input-vscode-config/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-vscode-config/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-vscode-config/package.json b/packages/plugin-input-vscode-config/package.json deleted file mode 100644 index 8c804656..00000000 --- a/packages/plugin-input-vscode-config/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-input-vscode-config", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-vscode-config for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-vscode-config/src/VSCodeConfigInputPlugin.ts b/packages/plugin-input-vscode-config/src/VSCodeConfigInputPlugin.ts deleted file mode 100644 index c975e526..00000000 --- a/packages/plugin-input-vscode-config/src/VSCodeConfigInputPlugin.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '@truenine/plugin-shared' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {FilePathKind, IDEKind} from '@truenine/plugin-shared' - -function readIdeConfigFile( - type: T, - relativePath: string, - shadowProjectDir: string, - fs: typeof import('node:fs'), - path: typeof import('node:path') -): ProjectIDEConfigFile | undefined { - const absPath = path.join(shadowProjectDir, relativePath) - if (!(fs.existsSync(absPath) && fs.statSync(absPath).isFile())) return void 0 - - const content = fs.readFileSync(absPath, 'utf8') - return { - type, - content, - length: content.length, - filePathKind: FilePathKind.Absolute, - dir: { - pathKind: FilePathKind.Absolute, - path: absPath, - getDirectoryName: () => path.basename(absPath) - } - } -} - -export class VSCodeConfigInputPlugin extends AbstractInputPlugin { - constructor() { - super('VSCodeConfigInputPlugin') - } - - collect(ctx: InputPluginContext): Partial { - const {userConfigOptions, fs, path} = ctx - const {shadowProjectDir} = this.resolveBasePaths(userConfigOptions) - - const files = ['.vscode/settings.json', '.vscode/extensions.json'] - const vscodeConfigFiles: ProjectIDEConfigFile[] = [] - - for (const relativePath of files) { - const file = readIdeConfigFile(IDEKind.VSCode, relativePath, shadowProjectDir, fs, path) - if (file != null) vscodeConfigFiles.push(file) - } - - return {vscodeConfigFiles} - } -} diff --git a/packages/plugin-input-vscode-config/src/index.ts b/packages/plugin-input-vscode-config/src/index.ts deleted file mode 100644 index 0d16869b..00000000 --- a/packages/plugin-input-vscode-config/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - VSCodeConfigInputPlugin -} from './VSCodeConfigInputPlugin' diff --git a/packages/plugin-input-vscode-config/tsconfig.eslint.json b/packages/plugin-input-vscode-config/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-vscode-config/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-vscode-config/tsconfig.json b/packages/plugin-input-vscode-config/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-vscode-config/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-vscode-config/tsconfig.lib.json b/packages/plugin-input-vscode-config/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-vscode-config/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-vscode-config/tsconfig.test.json b/packages/plugin-input-vscode-config/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-vscode-config/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-vscode-config/tsdown.config.ts b/packages/plugin-input-vscode-config/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-vscode-config/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-vscode-config/vite.config.ts b/packages/plugin-input-vscode-config/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-vscode-config/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-vscode-config/vitest.config.ts b/packages/plugin-input-vscode-config/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-vscode-config/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-input-workspace/eslint.config.ts b/packages/plugin-input-workspace/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-input-workspace/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-input-workspace/package.json b/packages/plugin-input-workspace/package.json deleted file mode 100644 index f99f8dba..00000000 --- a/packages/plugin-input-workspace/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-input-workspace", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "plugin-input-workspace for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-input-workspace/src/WorkspaceInputPlugin.ts b/packages/plugin-input-workspace/src/WorkspaceInputPlugin.ts deleted file mode 100644 index 2c46ad25..00000000 --- a/packages/plugin-input-workspace/src/WorkspaceInputPlugin.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type {CollectedInputContext, InputPluginContext, Workspace} from '@truenine/plugin-shared' -import * as path from 'node:path' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind -} from '@truenine/plugin-shared' - -export class WorkspaceInputPlugin extends AbstractInputPlugin { - constructor() { - super('WorkspaceInputPlugin') - } - - collect(ctx: InputPluginContext): Partial { - const {userConfigOptions: options} = ctx - const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(options) - - const workspace: Workspace = { - directory: { - pathKind: FilePathKind.Absolute, - path: workspaceDir, - getDirectoryName: () => path.basename(workspaceDir) - }, - projects: [] - } - - return { - workspace, - shadowSourceProjectDir: shadowProjectDir - } - } -} diff --git a/packages/plugin-input-workspace/src/index.ts b/packages/plugin-input-workspace/src/index.ts deleted file mode 100644 index 10051289..00000000 --- a/packages/plugin-input-workspace/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - WorkspaceInputPlugin -} from './WorkspaceInputPlugin' diff --git a/packages/plugin-input-workspace/tsconfig.eslint.json b/packages/plugin-input-workspace/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-input-workspace/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-input-workspace/tsconfig.json b/packages/plugin-input-workspace/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-input-workspace/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-workspace/tsconfig.lib.json b/packages/plugin-input-workspace/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-input-workspace/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-input-workspace/tsconfig.test.json b/packages/plugin-input-workspace/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-input-workspace/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-input-workspace/tsdown.config.ts b/packages/plugin-input-workspace/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-input-workspace/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-input-workspace/vite.config.ts b/packages/plugin-input-workspace/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-input-workspace/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-input-workspace/vitest.config.ts b/packages/plugin-input-workspace/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-input-workspace/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-jetbrains-ai-codex/eslint.config.ts b/packages/plugin-jetbrains-ai-codex/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-jetbrains-ai-codex/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-jetbrains-ai-codex/package.json b/packages/plugin-jetbrains-ai-codex/package.json deleted file mode 100644 index cb4aabd5..00000000 --- a/packages/plugin-jetbrains-ai-codex/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@truenine/plugin-jetbrains-ai-codex", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "JetBrains AI Assistant Codex output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/desk-paths": "workspace:*", - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-jetbrains-ai-codex/src/JetBrainsAIAssistantCodexOutputPlugin.test.ts b/packages/plugin-jetbrains-ai-codex/src/JetBrainsAIAssistantCodexOutputPlugin.test.ts deleted file mode 100644 index bb7fff4a..00000000 --- a/packages/plugin-jetbrains-ai-codex/src/JetBrainsAIAssistantCodexOutputPlugin.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import type { - CollectedInputContext, - FastCommandPrompt, - GlobalMemoryPrompt, - OutputPluginContext, - OutputWriteContext, - ProjectChildrenMemoryPrompt, - ProjectRootMemoryPrompt, - RelativePath, - SkillPrompt -} from '@truenine/plugin-shared' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import * as deskPaths from '@truenine/desk-paths' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {JetBrainsAIAssistantCodexOutputPlugin} from './JetBrainsAIAssistantCodexOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => path.basename(pathStr), - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -function createMockRootPath(pathStr: string): {pathKind: FilePathKind.Root, path: string, getDirectoryName: () => string} { - return { - pathKind: FilePathKind.Root, - path: pathStr, - getDirectoryName: () => path.basename(pathStr) - } -} - -function createGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt { - return { - type: PromptKind.GlobalMemory, - content, - dir: createMockRelativePath('.', basePath), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative, - parentDirectoryPath: { - type: 'UserHome', - directory: createMockRelativePath('.memory', basePath) - } - } as GlobalMemoryPrompt -} - -function createProjectRootMemoryPrompt(content: string, basePath: string): ProjectRootMemoryPrompt { - return { - type: PromptKind.ProjectRootMemory, - content, - dir: createMockRootPath(path.join(basePath, 'project')), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative - } as ProjectRootMemoryPrompt -} - -function createProjectChildMemoryPrompt( - basePath: string, - dirPath: string, - content: string -): ProjectChildrenMemoryPrompt { - return { - type: PromptKind.ProjectChildrenMemory, - content, - dir: createMockRelativePath(dirPath, basePath), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative, - workingChildDirectoryPath: createMockRelativePath(dirPath, basePath) - } as ProjectChildrenMemoryPrompt -} - -function createFastCommandPrompt( - basePath: string, - series: string | undefined, - commandName: string, - content: string, - rawFrontMatter?: string -): FastCommandPrompt { - return { - type: PromptKind.FastCommand, - series, - commandName, - content, - rawFrontMatter, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', basePath), - markdownContents: [] - } as FastCommandPrompt -} - -function createSkillPrompt(basePath: string, name: string, description: string): SkillPrompt { - return { - type: PromptKind.Skill, - yamlFrontMatter: { - name, - description, - displayName: 'Display Name', - version: '1.2.3', - author: 'Test Author', - keywords: ['alpha', 'beta'], - allowTools: ['toolA', 'toolB'] - }, - content: '# Skill Body', - length: 12, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('skill', basePath), - markdownContents: [], - childDocs: [ - { - type: PromptKind.SkillChildDoc, - dir: createMockRelativePath('references/guide.mdx', basePath), - content: '# Guide', - markdownContents: [], - length: 7, - filePathKind: FilePathKind.Relative - } - ], - resources: [ - { - type: PromptKind.SkillResource, - extension: '.txt', - fileName: 'notes.txt', - relativePath: 'assets/notes.txt', - content: 'resource-content', - encoding: 'text', - category: 'document', - length: 16 - } - ] - } as SkillPrompt -} - -function createMockOutputContext( - basePath: string, - collectedInputContext: Partial, - dryRun = false -): OutputWriteContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', basePath), - projects: [] - }, - ideConfigFiles: [], - ...collectedInputContext - } as CollectedInputContext, - dryRun - } -} - -function createJetBrainsCodexDir(basePath: string, ideName: string): string { - const codexDir = path.join(basePath, 'JetBrains', ideName, 'aia', 'codex') - fs.mkdirSync(codexDir, {recursive: true}) - return codexDir -} - -describe('jetBrainsAIAssistantCodexOutputPlugin', () => { - let tempDir: string, - plugin: JetBrainsAIAssistantCodexOutputPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jb-codex-test-')) - vi.spyOn(deskPaths, 'getPlatformFixedDir').mockReturnValue(tempDir) - plugin = new JetBrainsAIAssistantCodexOutputPlugin() - }) - - afterEach(() => { - vi.clearAllMocks() - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) - }) - - describe('registerGlobalOutputDirs', () => { - it('should register prompts and skill directories for supported IDEs', async () => { - createJetBrainsCodexDir(tempDir, 'IntelliJIdea2025.3') - createJetBrainsCodexDir(tempDir, 'WebStorm2025.1') - createJetBrainsCodexDir(tempDir, 'OtherIDE2025.1') - - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: {directory: createMockRelativePath('.', tempDir), projects: []}, - ideConfigFiles: [], - skills: [createSkillPrompt(tempDir, 'alpha-skill', 'alpha description')] - } as CollectedInputContext - } - - const results = await plugin.registerGlobalOutputDirs(ctx) - - const promptsDirs = results.filter(item => item.path === 'prompts') - const skillDirs = results.filter(item => item.path.endsWith(path.join('skills', 'alpha-skill'))) - - expect(promptsDirs).toHaveLength(2) - expect(skillDirs).toHaveLength(2) - expect(results.some(item => item.basePath.includes('OtherIDE'))).toBe(false) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register AGENTS.md for each supported IDE codex directory', async () => { - const ideaDir = createJetBrainsCodexDir(tempDir, 'IntelliJIdea2025.3') - const webstormDir = createJetBrainsCodexDir(tempDir, 'WebStorm2025.1') - - const results = await plugin.registerGlobalOutputFiles() - - expect(results).toHaveLength(2) - expect(results.map(r => r.getAbsolutePath())).toContain(path.join(ideaDir, 'AGENTS.md')) - expect(results.map(r => r.getAbsolutePath())).toContain(path.join(webstormDir, 'AGENTS.md')) - }) - }) - - describe('canWrite', () => { - it('should return false when no outputs exist', async () => { - const ctx = createMockOutputContext(tempDir, {}) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(false) - }) - - it('should return true when global memory is present', async () => { - const ctx = createMockOutputContext(tempDir, { - globalMemory: createGlobalMemoryPrompt('global', tempDir) - }) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(true) - }) - - it('should return true when project prompts are present', async () => { - const projectDir = createMockRelativePath('project-a', tempDir) - const ctx = createMockOutputContext(tempDir, { - workspace: { - directory: createMockRelativePath('.', tempDir), - projects: [ - { - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createProjectRootMemoryPrompt('root', tempDir), - childMemoryPrompts: [createProjectChildMemoryPrompt(tempDir, 'src', 'child')] - } - ] - } - }) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(true) - }) - }) - - describe('writeGlobalOutputs', () => { - it('should not write files during dry-run', async () => { - const codexDir = createJetBrainsCodexDir(tempDir, 'IntelliJIdea2025.3') - const ctx = createMockOutputContext( - tempDir, - { - globalMemory: createGlobalMemoryPrompt('global', tempDir), - fastCommands: [createFastCommandPrompt(tempDir, 'spec', 'build', 'body', 'title: Dry')], - skills: [createSkillPrompt(tempDir, 'dry-skill', 'dry description')] - }, - true - ) - - const result = await plugin.writeGlobalOutputs(ctx) - - expect(result.files.length).toBe(3) - expect(fs.existsSync(path.join(codexDir, 'AGENTS.md'))).toBe(false) - }) - - it('should write global memory, commands, and skills for each IDE', async () => { - const ideaDir = createJetBrainsCodexDir(tempDir, 'IntelliJIdea2025.3') - const webstormDir = createJetBrainsCodexDir(tempDir, 'WebStorm2025.1') - createJetBrainsCodexDir(tempDir, 'OtherIDE2025.1') - - const globalContent = 'GLOBAL MEMORY' - const fastCommand = createFastCommandPrompt(tempDir, 'spec', 'compile', 'command-body', 'title: Compile') - const skillName = 'My Skill !!!' - const skillDescription = 'Line 1\nLine 2' - const skill = createSkillPrompt(tempDir, skillName, skillDescription) - - const ctx = createMockOutputContext(tempDir, { - globalMemory: createGlobalMemoryPrompt(globalContent, tempDir), - fastCommands: [fastCommand], - skills: [skill] - }) - - const result = await plugin.writeGlobalOutputs(ctx) - - expect(result.files.length).toBeGreaterThan(0) - - const ideaAgents = path.join(ideaDir, 'AGENTS.md') - const webstormAgents = path.join(webstormDir, 'AGENTS.md') - expect(fs.readFileSync(ideaAgents, 'utf8')).toBe(globalContent) - expect(fs.readFileSync(webstormAgents, 'utf8')).toBe(globalContent) - - const commandFile = path.join(ideaDir, 'prompts', 'spec-compile.md') - const commandContent = fs.readFileSync(commandFile, 'utf8') - expect(commandContent).toContain('---') - expect(commandContent).toContain('title: Compile') - expect(commandContent).toContain('command-body') - - const skillDir = path.join(ideaDir, 'skills', skillName) - const skillFile = path.join(skillDir, 'SKILL.md') - const skillContent = fs.readFileSync(skillFile, 'utf8') - expect(skillContent).toContain('name: my-skill') - expect(skillContent).toContain('description: Line 1 Line 2') - expect(skillContent).toContain('allowed-tools: toolA toolB') - expect(skillContent).toContain('# Skill Body') - - const refFile = path.join(skillDir, 'references', 'guide.md') - expect(fs.readFileSync(refFile, 'utf8')).toBe('# Guide') - - const resourceFile = path.join(skillDir, 'assets', 'notes.txt') - expect(fs.readFileSync(resourceFile, 'utf8')).toBe('resource-content') - - const otherAgents = path.join(tempDir, 'JetBrains', 'OtherIDE2025.1', 'aia', 'codex', 'AGENTS.md') - expect(fs.existsSync(otherAgents)).toBe(false) - }) - }) - - describe('writeProjectOutputs', () => { - it('should write always and glob rules for project prompts', async () => { - const projectDir = createMockRelativePath('project-a', tempDir) - const rootContent = 'ROOT MEMORY' - const childContent = 'CHILD MEMORY' - const ctx = createMockOutputContext(tempDir, { - workspace: { - directory: createMockRelativePath('.', tempDir), - projects: [ - { - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createProjectRootMemoryPrompt(rootContent, tempDir), - childMemoryPrompts: [createProjectChildMemoryPrompt(tempDir, 'src', childContent)] - } - ] - } - }) - - const result = await plugin.writeProjectOutputs(ctx) - - expect(result.files.length).toBe(2) - - const rulesDir = path.join(tempDir, 'project-a', '.aiassistant', 'rules') - const rootFile = path.join(rulesDir, 'always.md') - const childFile = path.join(rulesDir, 'glob-src.md') - - const rootWritten = fs.readFileSync(rootFile, 'utf8') - expect(rootWritten).toContain('\u59CB\u7EC8') - expect(rootWritten).toContain(rootContent) - - const childWritten = fs.readFileSync(childFile, 'utf8') - expect(childWritten).toContain('\u6309\u6587\u4EF6\u6A21\u5F0F') - expect(childWritten).toContain('\u6A21\u5F0F') - expect(childWritten).toContain('src/**') - expect(childWritten).toContain(childContent) - }) - - it('should skip writes on dry-run for project prompts', async () => { - const projectDir = createMockRelativePath('project-a', tempDir) - const ctx = createMockOutputContext( - tempDir, - { - workspace: { - directory: createMockRelativePath('.', tempDir), - projects: [ - { - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createProjectRootMemoryPrompt('root', tempDir), - childMemoryPrompts: [createProjectChildMemoryPrompt(tempDir, 'src', 'child')] - } - ] - } - }, - true - ) - - const result = await plugin.writeProjectOutputs(ctx) - - expect(result.files.length).toBe(2) - expect(fs.existsSync(path.join(tempDir, 'project-a', '.aiassistant', 'rules', 'always.md'))).toBe(false) - }) - }) -}) diff --git a/packages/plugin-jetbrains-ai-codex/src/JetBrainsAIAssistantCodexOutputPlugin.ts b/packages/plugin-jetbrains-ai-codex/src/JetBrainsAIAssistantCodexOutputPlugin.ts deleted file mode 100644 index ac48d070..00000000 --- a/packages/plugin-jetbrains-ai-codex/src/JetBrainsAIAssistantCodexOutputPlugin.ts +++ /dev/null @@ -1,607 +0,0 @@ -import type { - FastCommandPrompt, - OutputPluginContext, - OutputWriteContext, - Project, - ProjectChildrenMemoryPrompt, - SkillPrompt, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {getPlatformFixedDir} from '@truenine/desk-paths' -import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' -import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' - -/** - * Represents the filename of the project memory file. - */ -const PROJECT_MEMORY_FILE = 'AGENTS.md' -/** - * Specifies the name of the subdirectory where prompt files are stored. - */ -const PROMPTS_SUBDIR = 'prompts' -/** - * Represents the name of the subdirectory where skill-related resources are stored. - */ -const SKILLS_SUBDIR = 'skills' -/** - * The file name that represents the skill definition file. - */ -const SKILL_FILE_NAME = 'SKILL.md' -const AIASSISTANT_DIR = '.aiassistant' -const RULES_SUBDIR = 'rules' -const ROOT_RULE_FILE = 'always.md' -const CHILD_RULE_FILE_PREFIX = 'glob-' -const RULE_APPLY_ALWAYS = '\u59CB\u7EC8' -const RULE_APPLY_GLOB = '\u6309\u6587\u4EF6\u6A21\u5F0F' -const RULE_GLOB_KEY = '\u6A21\u5F0F' -/** - * Represents the directory name used for storing JetBrains-related resources or files. - */ -const JETBRAINS_VENDOR_DIR = 'JetBrains' -/** - * Represents the directory path where the AIA files are stored. - */ -const AIA_DIR = 'aia' -/** - * Represents the directory path where the Codex-related files are stored. - */ -const CODEX_DIR = 'codex' - -/** - * An array of constant string literals representing the prefixes of JetBrains IDE directory names. - */ -const IDE_DIR_PREFIXES = [ - 'IntelliJIdea', - 'WebStorm', - 'RustRover', - 'PyCharm', - 'PyCharmCE', - 'PhpStorm', - 'GoLand', - 'CLion', - 'DataGrip', - 'RubyMine', - 'Rider', - 'DataSpell', - 'Aqua' -] as const - -/** - * Represents an output plugin specifically designed for integration with JetBrains AI Assistant Codex. - */ -export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('JetBrainsAIAssistantCodexOutputPlugin', { - outputFileName: PROJECT_MEMORY_FILE, - dependsOn: [PLUGIN_NAMES.AgentsOutput], - indexignore: '.aiignore' - }) - } - - async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - - for (const project of projects) { - if (project.dirFromWorkspacePath == null) continue - results.push(this.createProjectRulesDirRelativePath(project.dirFromWorkspacePath)) - } - - return results - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - - if (project.rootMemoryPrompt != null) results.push(this.createProjectRuleFileRelativePath(projectDir, ROOT_RULE_FILE)) - - if (project.childMemoryPrompts != null) { - for (const child of project.childMemoryPrompts) { - const fileName = this.buildChildRuleFileName(child) - results.push(this.createProjectRuleFileRelativePath(projectDir, fileName)) - } - } - } - - results.push(...this.registerProjectIgnoreOutputFiles(projects)) - return results - } - - async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const codexDirs = this.resolveCodexDirs() - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - - for (const codexDir of codexDirs) { - const promptsPath = path.join(codexDir, PROMPTS_SUBDIR) - results.push({ - pathKind: FilePathKind.Relative, - path: PROMPTS_SUBDIR, - basePath: codexDir, - getDirectoryName: () => PROMPTS_SUBDIR, - getAbsolutePath: () => promptsPath - }) - - const {skills} = ctx.collectedInputContext - if (skills == null || skills.length === 0) continue - - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - const skillPath = path.join(codexDir, SKILLS_SUBDIR, skillName) - results.push({ - pathKind: FilePathKind.Relative, - path: path.join(SKILLS_SUBDIR, skillName), - basePath: codexDir, - getDirectoryName: () => skillName, - getAbsolutePath: () => skillPath - }) - } - } - - return results - } - - async registerGlobalOutputFiles(): Promise { - const codexDirs = this.resolveCodexDirs() - return codexDirs.map(codexDir => ({ - pathKind: FilePathKind.Relative, - path: PROJECT_MEMORY_FILE, - basePath: codexDir, - getDirectoryName: () => CODEX_DIR, - getAbsolutePath: () => path.join(codexDir, PROJECT_MEMORY_FILE) - })) - } - - async canWrite(ctx: OutputWriteContext): Promise { - 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 || hasAiIgnore) return true - - this.log.trace({action: 'skip', reason: 'noOutputs'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const {projects} = ctx.collectedInputContext.workspace - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - - if (project.rootMemoryPrompt != null) { - const content = this.buildAlwaysRuleContent(project.rootMemoryPrompt.content as string) - const result = await this.writeProjectRuleFile(ctx, project, ROOT_RULE_FILE, content, 'projectRootRule') - fileResults.push(result) - } - - if (project.childMemoryPrompts != null) { - for (const child of project.childMemoryPrompts) { - const fileName = this.buildChildRuleFileName(child) - const content = this.buildGlobRuleContent(child) - const result = await this.writeProjectRuleFile(ctx, project, fileName, content, 'projectChildRule') - fileResults.push(result) - } - } - } - - const ignoreResults = await this.writeProjectIgnoreFiles(ctx) - fileResults.push(...ignoreResults) - - return {files: fileResults, dirs: dirResults} - } - - async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands, skills} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - const codexDirs = this.resolveCodexDirs() - - if (codexDirs.length === 0) return {files: fileResults, dirs: dirResults} - - const filteredCommands = fastCommands != null ? filterCommandsByProjectConfig(fastCommands, projectConfig) : [] - const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] - - for (const codexDir of codexDirs) { - if (globalMemory != null) { - const fullPath = path.join(codexDir, PROJECT_MEMORY_FILE) - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: PROJECT_MEMORY_FILE, - basePath: codexDir, - getDirectoryName: () => CODEX_DIR, - getAbsolutePath: () => fullPath - } - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'globalMemory', path: fullPath}) - fileResults.push({path: relativePath, success: true, skipped: false}) - } else { - try { - this.ensureDirectory(codexDir) - fs.writeFileSync(fullPath, globalMemory.content as string, 'utf8') - this.log.trace({action: 'write', type: 'globalMemory', path: fullPath}) - fileResults.push({path: relativePath, success: true}) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalMemory', path: fullPath, error: errMsg}) - fileResults.push({path: relativePath, success: false, error: error as Error}) - } - } - } - - if (filteredCommands.length > 0) { - for (const cmd of filteredCommands) { - const cmdResults = await this.writeGlobalFastCommand(ctx, codexDir, cmd) - fileResults.push(...cmdResults) - } - } - - if (filteredSkills.length === 0) continue - - for (const skill of filteredSkills) { - const skillResults = await this.writeGlobalSkill(ctx, codexDir, skill) - fileResults.push(...skillResults) - } - } - - return {files: fileResults, dirs: dirResults} - } - - private resolveCodexDirs(): string[] { - const baseDir = path.join(getPlatformFixedDir(), JETBRAINS_VENDOR_DIR) - if (!this.existsSync(baseDir)) return [] - - try { - const dirents = this.readdirSync(baseDir, {withFileTypes: true}) - const ideDirs = dirents.filter(dirent => { - if (!dirent.isDirectory()) return false - return this.isSupportedIdeDir(dirent.name) - }) - return ideDirs.map(dirent => path.join(baseDir, dirent.name, AIA_DIR, CODEX_DIR)) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.warn({action: 'scan', type: 'jetbrains', path: baseDir, error: errMsg}) - return [] - } - } - - private createProjectRulesDirRelativePath(projectDir: RelativePath): RelativePath { - const rulesDirPath = path.join(projectDir.path, AIASSISTANT_DIR, RULES_SUBDIR) - return { - pathKind: FilePathKind.Relative, - path: rulesDirPath, - basePath: projectDir.basePath, - getDirectoryName: () => RULES_SUBDIR, - getAbsolutePath: () => path.join(projectDir.basePath, rulesDirPath) - } - } - - private createProjectRuleFileRelativePath(projectDir: RelativePath, fileName: string): RelativePath { - const filePath = path.join(projectDir.path, AIASSISTANT_DIR, RULES_SUBDIR, fileName) - return { - pathKind: FilePathKind.Relative, - path: filePath, - basePath: projectDir.basePath, - getDirectoryName: () => RULES_SUBDIR, - getAbsolutePath: () => path.join(projectDir.basePath, filePath) - } - } - - private buildChildRuleFileName(child: ProjectChildrenMemoryPrompt): string { - const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path - const normalizedPath = childPath - .replaceAll('\\', '/') - .replaceAll(/^\/+|\/+$/g, '') - .replaceAll('/', '-') - - const suffix = normalizedPath.length > 0 ? normalizedPath : 'root' - return `${CHILD_RULE_FILE_PREFIX}${suffix}.md` - } - - private buildChildRulePattern(child: ProjectChildrenMemoryPrompt): string { - const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path - const normalizedPath = childPath - .replaceAll('\\', '/') - .replaceAll(/^\/+|\/+$/g, '') - - if (normalizedPath.length === 0) return '**/*' - return `${normalizedPath}/**` - } - - private buildAlwaysRuleContent(content: string): string { - const fmData: Record = { - apply: RULE_APPLY_ALWAYS - } - - return buildMarkdownWithFrontMatter(fmData, content) - } - - private buildGlobRuleContent(child: ProjectChildrenMemoryPrompt): string { - const pattern = this.buildChildRulePattern(child) - const fmData: Record = { - apply: RULE_APPLY_GLOB, - [RULE_GLOB_KEY]: pattern - } - - return buildMarkdownWithFrontMatter(fmData, child.content as string) - } - - private async writeProjectRuleFile( - ctx: OutputWriteContext, - project: Project, - fileName: string, - content: string, - label: string - ): Promise { - const projectDir = project.dirFromWorkspacePath! - const rulesDir = path.join(projectDir.basePath, projectDir.path, AIASSISTANT_DIR, RULES_SUBDIR) - const fullPath = path.join(rulesDir, fileName) - - const relativePath = this.createProjectRuleFileRelativePath(projectDir, fileName) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: label, path: fullPath}) - return {path: relativePath, success: true, skipped: false} - } - - try { - this.ensureDirectory(rulesDir) - fs.writeFileSync(fullPath, content, 'utf8') - this.log.trace({action: 'write', type: label, path: fullPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: label, path: fullPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private isSupportedIdeDir(dirName: string): boolean { - return IDE_DIR_PREFIXES.some(prefix => dirName.startsWith(prefix)) - } - - private async writeGlobalFastCommand( - ctx: OutputWriteContext, - codexDir: string, - cmd: FastCommandPrompt - ): Promise { - const results: WriteResult[] = [] - const transformOptions = this.getTransformOptionsFromContext(ctx) - const fileName = this.transformFastCommandName(cmd, transformOptions) - const targetDir = path.join(codexDir, PROMPTS_SUBDIR) - const fullPath = path.join(targetDir, fileName) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: path.join(PROMPTS_SUBDIR, fileName), - basePath: codexDir, - getDirectoryName: () => PROMPTS_SUBDIR, - getAbsolutePath: () => fullPath - } - - const content = this.buildMarkdownContentWithRaw( - cmd.content, - cmd.yamlFrontMatter, - cmd.rawFrontMatter - ) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'globalFastCommand', path: fullPath}) - return [{path: relativePath, success: true, skipped: false}] - } - - try { - this.ensureDirectory(targetDir) - fs.writeFileSync(fullPath, content, 'utf8') - this.log.trace({action: 'write', type: 'globalFastCommand', path: fullPath}) - results.push({path: relativePath, success: true}) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalFastCommand', path: fullPath, error: errMsg}) - results.push({path: relativePath, success: false, error: error as Error}) - } - - return results - } - - private async writeGlobalSkill( - ctx: OutputWriteContext, - codexDir: string, - skill: SkillPrompt - ): Promise { - const results: WriteResult[] = [] - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - const targetDir = path.join(codexDir, SKILLS_SUBDIR, skillName) - const fullPath = path.join(targetDir, SKILL_FILE_NAME) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: path.join(SKILLS_SUBDIR, skillName, SKILL_FILE_NAME), - basePath: codexDir, - getDirectoryName: () => skillName, - getAbsolutePath: () => fullPath - } - - const content = this.buildCodexSkillContent(skill) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'globalSkill', path: fullPath}) - return [{path: relativePath, success: true, skipped: false}] - } - - try { - this.ensureDirectory(targetDir) - fs.writeFileSync(fullPath, content, 'utf8') - this.log.trace({action: 'write', type: 'globalSkill', path: fullPath}) - results.push({path: relativePath, success: true}) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - const refResults = await this.writeSkillReferenceDocument(ctx, targetDir, skillName, refDoc, codexDir) - results.push(...refResults) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) { - const resourceResults = await this.writeSkillResource(ctx, targetDir, skillName, resource, codexDir) - results.push(...resourceResults) - } - } - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalSkill', path: fullPath, error: errMsg}) - results.push({path: relativePath, success: false, error: error as Error}) - } - - return results - } - - private buildCodexSkillContent(skill: SkillPrompt): string { - const fm = skill.yamlFrontMatter - - const name = this.normalizeSkillName(fm.name, 64) - const description = this.normalizeToSingleLine(fm.description, 1024) - - const metadata: Record = {} - - if (fm.displayName != null) metadata['short-description'] = fm.displayName - if (fm.version != null) metadata['version'] = fm.version - if (fm.author != null) metadata['author'] = fm.author - if (fm.keywords != null && fm.keywords.length > 0) metadata['keywords'] = [...fm.keywords] - - const fmData: Record = { - name, - description - } - - if (Object.keys(metadata).length > 0) fmData['metadata'] = metadata - if (fm.allowTools != null && fm.allowTools.length > 0) fmData['allowed-tools'] = fm.allowTools.join(' ') - - return buildMarkdownWithFrontMatter(fmData, skill.content as string) - } - - private normalizeSkillName(name: string, maxLength: number): string { - let normalized = name - .toLowerCase() - .replaceAll(/[^a-z0-9-]/g, '-') - .replaceAll(/-+/g, '-') - .replaceAll(/^-+|-+$/g, '') - - if (normalized.length > maxLength) normalized = normalized.slice(0, maxLength).replace(/-+$/, '') - - return normalized - } - - private normalizeToSingleLine(text: string, maxLength: number): string { - const singleLine = text.replaceAll(/[\r\n]+/g, ' ').replaceAll(/\s+/g, ' ').trim() - if (singleLine.length > maxLength) return `${singleLine.slice(0, maxLength - 3)}...` - return singleLine - } - - private async writeSkillReferenceDocument( - ctx: OutputWriteContext, - skillDir: string, - skillName: string, - refDoc: {dir: RelativePath, content: unknown}, - codexDir: string - ): Promise { - const results: WriteResult[] = [] - const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') - const fullPath = path.join(skillDir, fileName) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: path.join(SKILLS_SUBDIR, skillName, fileName), - basePath: codexDir, - getDirectoryName: () => skillName, - getAbsolutePath: () => fullPath - } - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'skillRefDoc', path: fullPath}) - return [{path: relativePath, success: true, skipped: false}] - } - - try { - const parentDir = path.dirname(fullPath) - this.ensureDirectory(parentDir) - fs.writeFileSync(fullPath, refDoc.content as string, 'utf8') - this.log.trace({action: 'write', type: 'skillRefDoc', path: fullPath}) - results.push({path: relativePath, success: true}) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'skillRefDoc', path: fullPath, error: errMsg}) - results.push({path: relativePath, success: false, error: error as Error}) - } - - return results - } - - private async writeSkillResource( - ctx: OutputWriteContext, - skillDir: string, - skillName: string, - resource: {relativePath: string, content: string}, - codexDir: string - ): Promise { - const results: WriteResult[] = [] - const fullPath = path.join(skillDir, resource.relativePath) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: path.join(SKILLS_SUBDIR, skillName, resource.relativePath), - basePath: codexDir, - getDirectoryName: () => skillName, - getAbsolutePath: () => fullPath - } - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'skillResource', path: fullPath}) - return [{path: relativePath, success: true, skipped: false}] - } - - try { - const parentDir = path.dirname(fullPath) - this.ensureDirectory(parentDir) - fs.writeFileSync(fullPath, resource.content, 'utf8') - this.log.trace({action: 'write', type: 'skillResource', path: fullPath}) - results.push({path: relativePath, success: true}) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'skillResource', path: fullPath, error: errMsg}) - results.push({path: relativePath, success: false, error: error as Error}) - } - - return results - } -} diff --git a/packages/plugin-jetbrains-ai-codex/src/index.ts b/packages/plugin-jetbrains-ai-codex/src/index.ts deleted file mode 100644 index 0a3c6461..00000000 --- a/packages/plugin-jetbrains-ai-codex/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - JetBrainsAIAssistantCodexOutputPlugin -} from './JetBrainsAIAssistantCodexOutputPlugin' diff --git a/packages/plugin-jetbrains-ai-codex/tsconfig.eslint.json b/packages/plugin-jetbrains-ai-codex/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-jetbrains-ai-codex/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-jetbrains-ai-codex/tsconfig.json b/packages/plugin-jetbrains-ai-codex/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-jetbrains-ai-codex/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-jetbrains-ai-codex/tsconfig.lib.json b/packages/plugin-jetbrains-ai-codex/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-jetbrains-ai-codex/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-jetbrains-ai-codex/tsconfig.test.json b/packages/plugin-jetbrains-ai-codex/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-jetbrains-ai-codex/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-jetbrains-ai-codex/tsdown.config.ts b/packages/plugin-jetbrains-ai-codex/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-jetbrains-ai-codex/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-jetbrains-ai-codex/vite.config.ts b/packages/plugin-jetbrains-ai-codex/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-jetbrains-ai-codex/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-jetbrains-ai-codex/vitest.config.ts b/packages/plugin-jetbrains-ai-codex/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-jetbrains-ai-codex/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-jetbrains-codestyle/eslint.config.ts b/packages/plugin-jetbrains-codestyle/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-jetbrains-codestyle/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-jetbrains-codestyle/package.json b/packages/plugin-jetbrains-codestyle/package.json deleted file mode 100644 index f21fffda..00000000 --- a/packages/plugin-jetbrains-codestyle/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-jetbrains-codestyle", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "JetBrains IDE code style config output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-jetbrains-codestyle/src/JetBrainsIDECodeStyleConfigOutputPlugin.ts b/packages/plugin-jetbrains-codestyle/src/JetBrainsIDECodeStyleConfigOutputPlugin.ts deleted file mode 100644 index 1aa7e340..00000000 --- a/packages/plugin-jetbrains-codestyle/src/JetBrainsIDECodeStyleConfigOutputPlugin.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { - OutputPluginContext, - OutputWriteContext, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {FilePathKind, IDEKind} from '@truenine/plugin-shared' - -const IDEA_DIR = '.idea' -const CODE_STYLES_DIR = 'codeStyles' - -/** - * Default JetBrains IDE config files that this plugin manages. - * These are the relative paths within each project directory. - */ -const JETBRAINS_CONFIG_FILES = [ - '.editorconfig', - '.idea/codeStyles/Project.xml', - '.idea/codeStyles/codeStyleConfig.xml', - '.idea/.gitignore' -] as const - -export class JetBrainsIDECodeStyleConfigOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('JetBrainsIDECodeStyleConfigOutputPlugin') - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - const {jetbrainsConfigFiles, editorConfigFiles} = ctx.collectedInputContext - - const hasJetBrainsConfigs = (jetbrainsConfigFiles != null && jetbrainsConfigFiles.length > 0) - || (editorConfigFiles != null && editorConfigFiles.length > 0) - if (!hasJetBrainsConfigs) return results - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - - if (project.isPromptSourceProject === true) continue - - for (const configFile of JETBRAINS_CONFIG_FILES) { - const filePath = this.joinPath(projectDir.path, configFile) - results.push({ - pathKind: FilePathKind.Relative, - path: filePath, - basePath: projectDir.basePath, - getDirectoryName: () => this.dirname(configFile), - getAbsolutePath: () => this.resolvePath(projectDir.basePath, filePath) - }) - } - } - - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {jetbrainsConfigFiles, editorConfigFiles} = ctx.collectedInputContext - const hasIdeaConfigs = (jetbrainsConfigFiles != null && jetbrainsConfigFiles.length > 0) - || (editorConfigFiles != null && editorConfigFiles.length > 0) - - if (hasIdeaConfigs) return true - - this.log.debug('skipped', {reason: 'no JetBrains IDE config files found'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const {projects} = ctx.collectedInputContext.workspace - const {jetbrainsConfigFiles, editorConfigFiles} = ctx.collectedInputContext - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - const jetbrainsConfigs = [ - ...jetbrainsConfigFiles ?? [], - ...editorConfigFiles ?? [] - ] - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - - const projectName = project.name ?? 'unknown' - - for (const config of jetbrainsConfigs) { - const result = await this.writeConfigFile(ctx, projectDir, config, `project:${projectName}`) - fileResults.push(result) - } - } - - return {files: fileResults, dirs: dirResults} - } - - private async writeConfigFile( - ctx: OutputWriteContext, - projectDir: RelativePath, - config: {type: IDEKind, content: string, dir: {path: string}}, - label: string - ): Promise { - const targetRelativePath = this.getTargetRelativePath(config) - const fullPath = this.resolvePath(projectDir.basePath, projectDir.path, targetRelativePath) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: this.joinPath(projectDir.path, targetRelativePath), - basePath: projectDir.basePath, - getDirectoryName: () => this.dirname(targetRelativePath), - getAbsolutePath: () => fullPath - } - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'config', path: fullPath, label}) - return {path: relativePath, success: true, skipped: false} - } - - try { - const dir = this.dirname(fullPath) - this.ensureDirectory(dir) - this.writeFileSync(fullPath, config.content) - this.log.trace({action: 'write', type: 'config', 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: 'config', path: fullPath, label, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private getTargetRelativePath(config: {type: IDEKind, dir: {path: string}}): string { - const sourcePath = config.dir.path - - if (config.type === IDEKind.EditorConfig) return '.editorconfig' - - if (config.type !== IDEKind.IntellijIDEA) return this.basename(sourcePath) - - const ideaIndex = sourcePath.indexOf(IDEA_DIR) - if (ideaIndex !== -1) return sourcePath.slice(Math.max(0, ideaIndex)) - return this.joinPath(IDEA_DIR, CODE_STYLES_DIR, this.basename(sourcePath)) - } -} diff --git a/packages/plugin-jetbrains-codestyle/src/index.ts b/packages/plugin-jetbrains-codestyle/src/index.ts deleted file mode 100644 index 768102b3..00000000 --- a/packages/plugin-jetbrains-codestyle/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - JetBrainsIDECodeStyleConfigOutputPlugin -} from './JetBrainsIDECodeStyleConfigOutputPlugin' diff --git a/packages/plugin-jetbrains-codestyle/tsconfig.eslint.json b/packages/plugin-jetbrains-codestyle/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-jetbrains-codestyle/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-jetbrains-codestyle/tsconfig.json b/packages/plugin-jetbrains-codestyle/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-jetbrains-codestyle/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-jetbrains-codestyle/tsconfig.lib.json b/packages/plugin-jetbrains-codestyle/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-jetbrains-codestyle/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-jetbrains-codestyle/tsconfig.test.json b/packages/plugin-jetbrains-codestyle/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-jetbrains-codestyle/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-jetbrains-codestyle/tsdown.config.ts b/packages/plugin-jetbrains-codestyle/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-jetbrains-codestyle/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-jetbrains-codestyle/vite.config.ts b/packages/plugin-jetbrains-codestyle/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-jetbrains-codestyle/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-jetbrains-codestyle/vitest.config.ts b/packages/plugin-jetbrains-codestyle/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-jetbrains-codestyle/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-kiro-ide/env.d.ts b/packages/plugin-kiro-ide/env.d.ts deleted file mode 100644 index 202c8664..00000000 --- a/packages/plugin-kiro-ide/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare const __KIRO_GLOBAL_POWERS_REGISTRY__: string | undefined diff --git a/packages/plugin-kiro-ide/eslint.config.ts b/packages/plugin-kiro-ide/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-kiro-ide/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-kiro-ide/package.json b/packages/plugin-kiro-ide/package.json deleted file mode 100644 index 344a4ec0..00000000 --- a/packages/plugin-kiro-ide/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@truenine/plugin-kiro-ide", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Kiro IDE output plugin", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "picomatch": "catalog:" - } -} diff --git a/packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.projectConfig.test.ts b/packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.projectConfig.test.ts deleted file mode 100644 index ba3cc19f..00000000 --- a/packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.projectConfig.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type {OutputPluginContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {KiroCLIOutputPlugin} from './KiroCLIOutputPlugin' - -class TestableKiroCLIOutputPlugin extends KiroCLIOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -function createMockContext( - tempDir: string, - rules: unknown[], - projects: unknown[] -): OutputPluginContext { - return { - collectedInputContext: { - workspace: { - projects: projects as never, - directory: { - pathKind: 1, - path: tempDir, - basePath: tempDir, - getDirectoryName: () => 'workspace', - getAbsolutePath: () => tempDir - } - }, - ideConfigFiles: [], - rules: rules as never, - fastCommands: [], - skills: [], - globalMemory: void 0, - aiAgentIgnoreConfigFiles: [] - }, - logger: { - debug: vi.fn(), - trace: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } as never, - fs, - path, - glob: vi.fn() as never - } -} - -describe('kiroCLIOutputPlugin - projectConfig filtering', () => { - let tempDir: string, - plugin: TestableKiroCLIOutputPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-proj-config-test-')) - plugin = new TestableKiroCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch {} - }) - - describe('registerProjectOutputFiles', () => { - it('should include all project rules when no projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [createMockProject('proj1', tempDir, 'proj1')] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).toContain('rule-test-rule2.md') - }) - - it('should filter rules by include in projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).not.toContain('rule-test-rule2.md') - }) - - it('should filter rules by includeSeries excluding non-matching series', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).not.toContain('rule-test-rule1.md') - expect(fileNames).toContain('rule-test-rule2.md') - }) - - it('should include rules without seriName regardless of include filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', void 0, 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).not.toContain('rule-test-rule2.md') - }) - - it('should filter independently for each project', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}), - createMockProject('proj2', tempDir, 'proj2', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = results.map(r => ({ - path: r.path, - fileName: r.path.split(/[/\\]/).pop() - })) - - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule1.md')).toBe(true) - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule2.md')).toBe(false) - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule2.md')).toBe(true) - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule1.md')).toBe(false) - }) - - it('should return empty when include matches nothing', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const ruleFiles = results.filter(r => r.path.includes('rule-')) - - expect(ruleFiles).toHaveLength(0) - }) - }) - - describe('writeProjectOutputs', () => { - it('should write only filtered rules to each project', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files.some(f => f.path.path.includes('rule-test-rule1.md'))).toBe(true) - expect(results.files.some(f => f.path.path.includes('rule-test-rule2.md'))).toBe(false) - }) - }) -}) diff --git a/packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.test.ts b/packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.test.ts deleted file mode 100644 index f983ae23..00000000 --- a/packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.test.ts +++ /dev/null @@ -1,766 +0,0 @@ -import type {CollectedInputContext, FastCommandPrompt, OutputPluginContext, RelativePath, SkillYAMLFrontMatter} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {KiroCLIOutputPlugin} from './KiroCLIOutputPlugin' - -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 TestableKiroCLIOutputPlugin extends KiroCLIOutputPlugin { // Create a testable subclass to expose private methods - private mockHomeDir: string | null = null - - public testBuildFastCommandSteeringFileName(cmd: FastCommandPrompt): string { - return (this as any).buildFastCommandSteeringFileName(cmd) // Access private method via any cast - } - - public testBuildPowerFrontMatter(frontMatter: SkillYAMLFrontMatter): string { - return (this as any).buildPowerFrontMatter(frontMatter) // Access private method via any cast - } - - public testBuildSkillFrontMatter(frontMatter: SkillYAMLFrontMatter): string { - return (this as any).buildSkillFrontMatter(frontMatter) // Access private method via any cast - } - - public testListInstalledPowers(powersDir: string): string[] { - return (this as any).listInstalledPowers(powersDir) // Access private method via any cast - } - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { // Override getHomeDir to allow mocking in tests - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -describe('kiroCLIOutputPlugin', () => { - describe('buildFastCommandSteeringFileName', () => { - const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) // Generator for alphanumeric strings without underscore (for series prefix) - .filter(s => /^[a-z0-9]+$/i.test(s)) - - const alphanumericCommandName = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generator for alphanumeric strings (for command name) - .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 TestableKiroCLIOutputPlugin() - const cmd = createMockFastCommandPrompt(series, commandName) - - const result = plugin.testBuildFastCommandSteeringFileName(cmd) - - expect(result).toBe(`${series}-${commandName}.md`) // Should use hyphen separator instead of underscore - } - ), - {numRuns: 100} - ) - }) - - it('should return just commandName.md when series is undefined', () => { - fc.assert( - fc.property( - alphanumericCommandName, - commandName => { - const plugin = new TestableKiroCLIOutputPlugin() - const cmd = createMockFastCommandPrompt(void 0, commandName) - - const result = plugin.testBuildFastCommandSteeringFileName(cmd) - - expect(result).toBe(`${commandName}.md`) // Should return just commandName without any prefix - } - ), - {numRuns: 100} - ) - }) - - it('should transform pe_compile to pe-compile.md', () => { // Unit tests for specific examples - const plugin = new TestableKiroCLIOutputPlugin() - const cmd = createMockFastCommandPrompt('pe', 'compile') - - const result = plugin.testBuildFastCommandSteeringFileName(cmd) - - expect(result).toBe('pe-compile.md') - }) - - it('should transform spec_requirement to spec-requirement.md', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const cmd = createMockFastCommandPrompt('spec', 'requirement') - - const result = plugin.testBuildFastCommandSteeringFileName(cmd) - - expect(result).toBe('spec-requirement.md') - }) - - it('should handle command without series', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const cmd = createMockFastCommandPrompt(void 0, 'compile') - - const result = plugin.testBuildFastCommandSteeringFileName(cmd) - - expect(result).toBe('compile.md') - }) - }) - - describe('buildPowerFrontMatter', () => { - it('should include name and description in front matter', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const frontMatter = { - name: 'test-skill', - description: 'A test skill' - } as SkillYAMLFrontMatter - - const result = plugin.testBuildPowerFrontMatter(frontMatter) - - expect(result).toContain('---') - expect(result).toContain('name: test-skill') - expect(result).toContain('description: A test skill') - }) - - it('should include displayName when provided', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const frontMatter = { - name: 'test-skill', - description: 'A test skill', - displayName: 'Test Skill Display' - } as SkillYAMLFrontMatter - - const result = plugin.testBuildPowerFrontMatter(frontMatter) - - expect(result).toContain('displayName: Test Skill Display') - }) - - it('should include keywords array when provided', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const frontMatter = { - name: 'test-skill', - description: 'A test skill', - keywords: ['typescript', 'testing', 'cli'] - } as SkillYAMLFrontMatter - - const result = plugin.testBuildPowerFrontMatter(frontMatter) - - expect(result).toContain('keywords:') // YAML library outputs arrays in block style - expect(result).toContain('- typescript') - expect(result).toContain('- testing') - expect(result).toContain('- cli') - }) - - it('should include author when provided', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const frontMatter = { - name: 'test-skill', - description: 'A test skill', - author: 'Test Author' - } as SkillYAMLFrontMatter - - const result = plugin.testBuildPowerFrontMatter(frontMatter) - - expect(result).toContain('author: Test Author') - }) - - it('should omit optional fields when not provided', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const frontMatter = { - name: 'minimal-skill', - description: '' - } as SkillYAMLFrontMatter - - const result = plugin.testBuildPowerFrontMatter(frontMatter) - - expect(result).not.toContain('displayName') - expect(result).not.toContain('keywords') - expect(result).not.toContain('author') - }) - - it('should produce valid YAML front matter format', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const frontMatter = { - name: 'full-skill', - description: 'Full featured skill', - displayName: 'Full Skill', - keywords: ['feature', 'complete'], - author: 'Developer' - } as SkillYAMLFrontMatter - - const result = plugin.testBuildPowerFrontMatter(frontMatter) - - expect(result.startsWith('---')).toBe(true) - expect(result.endsWith('---')).toBe(true) - - const lines = result.split('\n') // Should have proper line structure - expect(lines[0]).toBe('---') - expect(lines.at(-1)).toBe('---') - }) - }) - - describe('registerGlobalOutputDirs - clean all installed powers', () => { - let tempDir: string - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-test-')) // Create a temporary directory for testing - }) - - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Clean up temporary directory - }) - - it('should list all installed power directories', () => { - const plugin = new TestableKiroCLIOutputPlugin() - - const powersDir = path.join(tempDir, '.kiro', 'powers', 'installed') // Create mock power directories - fs.mkdirSync(powersDir, {recursive: true}) - fs.mkdirSync(path.join(powersDir, 'power-a')) - fs.mkdirSync(path.join(powersDir, 'power-b')) - fs.mkdirSync(path.join(powersDir, 'old-power')) - - const result = plugin.testListInstalledPowers(powersDir) - - expect(result).toHaveLength(3) - expect(result).toContain('power-a') - expect(result).toContain('power-b') - expect(result).toContain('old-power') - }) - - it('should return empty array when powers directory does not exist', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const nonExistentDir = path.join(tempDir, 'non-existent') - - const result = plugin.testListInstalledPowers(nonExistentDir) - - expect(result).toEqual([]) - }) - - it('should only list directories, not files', () => { - const plugin = new TestableKiroCLIOutputPlugin() - - const powersDir = path.join(tempDir, '.kiro', 'powers', 'installed') // Create mock power directories and files - fs.mkdirSync(powersDir, {recursive: true}) - fs.mkdirSync(path.join(powersDir, 'valid-power')) - fs.writeFileSync(path.join(powersDir, 'not-a-power.txt'), 'content') - - const result = plugin.testListInstalledPowers(powersDir) - - expect(result).toHaveLength(1) - expect(result).toContain('valid-power') - expect(result).not.toContain('not-a-power.txt') - }) - - it('should register all installed powers for cleanup in registerGlobalOutputDirs', async () => { - const plugin = new TestableKiroCLIOutputPlugin() - - const powersDir = path.join(tempDir, '.kiro', 'powers', 'installed') // Create mock power directories - fs.mkdirSync(powersDir, {recursive: true}) - fs.mkdirSync(path.join(powersDir, 'current-skill')) - fs.mkdirSync(path.join(powersDir, 'old-removed-skill')) - fs.mkdirSync(path.join(powersDir, 'renamed-skill')) - - plugin.setMockHomeDir(tempDir) // Mock the home directory to use our temp dir - - const ctx: OutputPluginContext = { // Create a minimal context with no skills (simulating clean after skills removed) - collectedInputContext: { - workspace: { - projects: [] - }, - skills: [] // No current skills - simulating clean after skills removed - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path - } - - const result = await plugin.registerGlobalOutputDirs(ctx) - - const powerDirs = result.filter(r => r.basePath.includes('powers') && r.basePath.includes('installed')) // Use path separator agnostic check for cross-platform compatibility // Should include all installed powers, not just current skills - expect(powerDirs).toHaveLength(3) - - const powerNames = powerDirs.map(r => r.path) - expect(powerNames).toContain('current-skill') - expect(powerNames).toContain('old-removed-skill') - expect(powerNames).toContain('renamed-skill') - }) - }) - - describe('mCP configuration output', () => { - let tempDir: string - - beforeEach(() => tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-mcp-test-'))) - - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) - }) - - it('should register mcp.json in power directory when skill has MCP config', async () => { - const plugin = new TestableKiroCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: {projects: [], directory: {path: tempDir, pathKind: FilePathKind.Absolute, getDirectoryName: () => 'test'}}, - ideConfigFiles: [], - skills: [ - { - type: PromptKind.Skill, - yamlFrontMatter: {name: 'test-skill', description: 'Test'}, - content: '# Test', - length: 6, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-skill', tempDir), - markdownContents: [], - mcpConfig: { - type: PromptKind.SkillMcpConfig, - mcpServers: { - 'test-server': {command: 'uvx', args: ['test-package']} - }, - rawContent: '{"mcpServers":{"test-server":{"command":"uvx","args":["test-package"]}}}' - } - } - ] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path - } - - const result = await plugin.registerGlobalOutputFiles(ctx) - - const mcpFile = result.find(r => r.path === 'mcp.json') // Should include mcp.json in the power directory - expect(mcpFile).toBeDefined() - expect(mcpFile?.basePath).toMatch(/powers[/\\]installed[/\\]test-skill/) // Use path separator agnostic check for cross-platform compatibility - }) - - it('should not register mcp.json when skill has no MCP config', async () => { - const plugin = new TestableKiroCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: {projects: [], directory: {path: tempDir, pathKind: FilePathKind.Absolute, getDirectoryName: () => 'test'}}, - ideConfigFiles: [], - skills: [ - { - type: PromptKind.Skill, - yamlFrontMatter: {name: 'test-skill', description: 'Test'}, - content: '# Test', - length: 6, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-skill', tempDir), - markdownContents: [] - } // No mcpConfig - ] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path - } - - const result = await plugin.registerGlobalOutputFiles(ctx) - - const mcpFile = result.find(r => r.path === 'mcp.json') // Should NOT include mcp.json - expect(mcpFile).toBeUndefined() - }) - - it('should write mcp.json when calling writeGlobalOutputs with skill that has MCP config', async () => { - const plugin = new TestableKiroCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - const powersDir = path.join(tempDir, '.kiro', 'powers', 'installed', 'my-skill') - fs.mkdirSync(powersDir, {recursive: true}) - - const mcpRawContent = JSON.stringify({ - mcpServers: { - 'my-server': {command: 'uvx', args: ['my-package'], env: {KEY: 'value'}} - } - }, null, 2) - - const ctx = { - dryRun: false, - collectedInputContext: { - workspace: {projects: [], directory: {path: tempDir, pathKind: FilePathKind.Absolute, getDirectoryName: () => 'test'}}, - ideConfigFiles: [], - globalMemory: void 0, - fastCommands: [], - skills: [ - { - type: PromptKind.Skill, - yamlFrontMatter: {name: 'my-skill', description: 'My Skill'}, - content: '# My Skill', - length: 10, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('my-skill', tempDir), - markdownContents: [], - mcpConfig: { - type: PromptKind.SkillMcpConfig, - mcpServers: { - 'my-server': {command: 'uvx', args: ['my-package'], env: {KEY: 'value'}} - }, - rawContent: mcpRawContent - } - } - ] - }, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - - await plugin.writeGlobalOutputs(ctx as any) - - const mcpConfigPath = path.join(powersDir, 'mcp.json') - expect(fs.existsSync(mcpConfigPath)).toBe(true) - const writtenContent = fs.readFileSync(mcpConfigPath, 'utf8') - expect(writtenContent).toBe(mcpRawContent) - }) - - it('should register mcp.json for each skill with MCP config separately', async () => { - const plugin = new TestableKiroCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: {projects: [], directory: {path: tempDir, pathKind: FilePathKind.Absolute, getDirectoryName: () => 'test'}}, - ideConfigFiles: [], - skills: [ - { - type: PromptKind.Skill, - yamlFrontMatter: {name: 'skill-a', description: 'Skill A'}, - content: '# Skill A', - length: 9, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('skill-a', tempDir), - markdownContents: [], - mcpConfig: { - type: PromptKind.SkillMcpConfig, - mcpServers: {server1: {command: 'cmd1'}}, - rawContent: '{}' - } - }, - { - type: PromptKind.Skill, - yamlFrontMatter: {name: 'skill-b', description: 'Skill B'}, - content: '# Skill B', - length: 9, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('skill-b', tempDir), - markdownContents: [], - mcpConfig: { - type: PromptKind.SkillMcpConfig, - mcpServers: {server2: {command: 'cmd2'}}, - rawContent: '{}' - } - }, - { - type: PromptKind.Skill, - yamlFrontMatter: {name: 'skill-c', description: 'Skill C (no MCP)'}, - content: '# Skill C', - length: 9, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('skill-c', tempDir), - markdownContents: [] - } // No mcpConfig - ] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path - } - - const result = await plugin.registerGlobalOutputFiles(ctx) - - const mcpFiles = result.filter(r => r.path === 'mcp.json') // Should have mcp.json for skill-a, skill-b (in power dirs), and one global settings/mcp.json - expect(mcpFiles).toHaveLength(3) // 2 power mcp.json + 1 global settings/mcp.json = 3 - - const powerMcpFiles = mcpFiles.filter(f => f.basePath.includes('powers') && f.basePath.includes('installed')) // Check power directory mcp.json files - use path separator agnostic check - expect(powerMcpFiles).toHaveLength(2) - - const mcpBasePaths = powerMcpFiles.map(f => f.basePath) - expect(mcpBasePaths.some(p => p.includes('skill-a'))).toBe(true) - expect(mcpBasePaths.some(p => p.includes('skill-b'))).toBe(true) - expect(mcpBasePaths.some(p => p.includes('skill-c'))).toBe(false) - - const globalMcpFile = mcpFiles.find(f => f.basePath.includes('settings')) // Check global settings/mcp.json - expect(globalMcpFile).toBeDefined() - - const skillFile = result.find(r => r.path === 'SKILL.md') // skill-c (no MCP) should go to skills dir with SKILL.md - expect(skillFile).toBeDefined() - expect(skillFile?.basePath).toMatch(/\.kiro[/\\]skills[/\\]skill-c/) - }) - }) - - describe('skill routing: MCP vs non-MCP', () => { - let tempDir: string - - beforeEach(() => tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-routing-test-'))) - - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) - }) - - it('should route skill with MCP to powers dir with POWER.md', async () => { - const plugin = new TestableKiroCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: {projects: []}, - skills: [ - { - type: PromptKind.Skill, - yamlFrontMatter: {name: 'mcp-skill', description: 'Has MCP'}, - content: '# MCP Skill', - length: 11, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('mcp-skill', tempDir), - markdownContents: [], - mcpConfig: { - type: PromptKind.SkillMcpConfig, - mcpServers: {'my-server': {command: 'uvx', args: ['pkg']}}, - rawContent: '{}' - } - } - ] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path - } - - const result = await plugin.registerGlobalOutputFiles(ctx) - - const powerFile = result.find(r => r.path === 'POWER.md') - expect(powerFile).toBeDefined() - expect(powerFile?.basePath).toMatch(/powers[/\\]installed[/\\]mcp-skill/) - - const skillFile = result.find(r => r.path === 'SKILL.md') - expect(skillFile).toBeUndefined() - }) - - it('should route skill without MCP to skills dir with SKILL.md', async () => { - const plugin = new TestableKiroCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: {projects: []}, - skills: [ - { - type: PromptKind.Skill, - yamlFrontMatter: {name: 'plain-skill', description: 'No MCP'}, - content: '# Plain Skill', - length: 13, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('plain-skill', tempDir), - markdownContents: [] - } - ] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path - } - - const result = await plugin.registerGlobalOutputFiles(ctx) - - const skillFile = result.find(r => r.path === 'SKILL.md') - expect(skillFile).toBeDefined() - expect(skillFile?.basePath).toMatch(/\.kiro[/\\]skills[/\\]plain-skill/) - - const powerFile = result.find(r => r.path === 'POWER.md') - expect(powerFile).toBeUndefined() - }) - - it('should write skill without MCP to ~/.kiro/skills//SKILL.md', async () => { - const plugin = new TestableKiroCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - const ctx = { - dryRun: false, - collectedInputContext: { - workspace: {projects: []}, - globalMemory: void 0, - fastCommands: [], - skills: [ - { - type: PromptKind.Skill, - yamlFrontMatter: {name: 'plain-skill', description: 'No MCP'}, - content: '# Plain Skill Content', - length: 21, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('plain-skill', tempDir), - markdownContents: [] - } - ] - }, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - - await plugin.writeGlobalOutputs(ctx as any) - - const skillFilePath = path.join(tempDir, '.kiro', 'skills', 'plain-skill', 'SKILL.md') - expect(fs.existsSync(skillFilePath)).toBe(true) - const content = fs.readFileSync(skillFilePath, 'utf8') - expect(content).toContain('name: plain-skill') - expect(content).toContain('# Plain Skill Content') - - const powerFilePath = path.join(tempDir, '.kiro', 'powers', 'installed', 'plain-skill', 'POWER.md') - expect(fs.existsSync(powerFilePath)).toBe(false) - }) - - it('should write skill with MCP to ~/.kiro/powers/installed//POWER.md', async () => { - const plugin = new TestableKiroCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - const mcpRawContent = JSON.stringify({mcpServers: {srv: {command: 'uvx'}}}, null, 2) - - const ctx = { - dryRun: false, - collectedInputContext: { - workspace: {projects: []}, - globalMemory: void 0, - fastCommands: [], - skills: [ - { - type: PromptKind.Skill, - yamlFrontMatter: {name: 'mcp-skill', description: 'Has MCP'}, - content: '# MCP Content', - length: 14, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('mcp-skill', tempDir), - markdownContents: [], - mcpConfig: { - type: PromptKind.SkillMcpConfig, - mcpServers: {srv: {command: 'uvx'}}, - rawContent: mcpRawContent - } - } - ] - }, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - - await plugin.writeGlobalOutputs(ctx as any) - - const powerFilePath = path.join(tempDir, '.kiro', 'powers', 'installed', 'mcp-skill', 'POWER.md') - expect(fs.existsSync(powerFilePath)).toBe(true) - const content = fs.readFileSync(powerFilePath, 'utf8') - expect(content).toContain('name: mcp-skill') - expect(content).toContain('# MCP Content') - - const mcpConfigPath = path.join(tempDir, '.kiro', 'powers', 'installed', 'mcp-skill', 'mcp.json') - expect(fs.existsSync(mcpConfigPath)).toBe(true) - - const skillFilePath = path.join(tempDir, '.kiro', 'skills', 'mcp-skill', 'SKILL.md') - expect(fs.existsSync(skillFilePath)).toBe(false) - }) - - it('should register skills dir entries for cleanup in registerGlobalOutputDirs', async () => { - const plugin = new TestableKiroCLIOutputPlugin() - - const skillsDir = path.join(tempDir, '.kiro', 'skills') - fs.mkdirSync(skillsDir, {recursive: true}) - fs.mkdirSync(path.join(skillsDir, 'old-skill')) - fs.mkdirSync(path.join(skillsDir, 'another-skill')) - - plugin.setMockHomeDir(tempDir) - - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: {projects: []}, - skills: [] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path - } - - const result = await plugin.registerGlobalOutputDirs(ctx) - - const skillDirs = result.filter(r => r.basePath.includes('.kiro') && r.basePath.includes('skills') && !r.basePath.includes('powers')) - expect(skillDirs).toHaveLength(2) - - const skillNames = skillDirs.map(r => r.path) - expect(skillNames).toContain('old-skill') - expect(skillNames).toContain('another-skill') - }) - }) - - describe('buildSkillFrontMatter', () => { - it('should include name and description', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const fm = {name: 'my-skill', description: 'A skill'} as SkillYAMLFrontMatter - - const result = plugin.testBuildSkillFrontMatter(fm) - - expect(result).toContain('---') - expect(result).toContain('name: my-skill') - expect(result).toContain('description: A skill') - }) - - it('should include optional fields when provided', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const fm = { - name: 'full-skill', - description: 'Full', - displayName: 'Full Skill', - keywords: ['a', 'b'], - author: 'TrueNine' - } as SkillYAMLFrontMatter - - const result = plugin.testBuildSkillFrontMatter(fm) - - expect(result).toContain('displayName: Full Skill') - expect(result).toContain('- a') - expect(result).toContain('- b') - expect(result).toContain('author: TrueNine') - }) - - it('should omit optional fields when not provided', () => { - const plugin = new TestableKiroCLIOutputPlugin() - const fm = {name: 'minimal', description: ''} as SkillYAMLFrontMatter - - const result = plugin.testBuildSkillFrontMatter(fm) - - expect(result).not.toContain('displayName') - expect(result).not.toContain('keywords') - expect(result).not.toContain('author') - }) - }) -}) diff --git a/packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.ts b/packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.ts deleted file mode 100644 index f72ffff7..00000000 --- a/packages/plugin-kiro-ide/src/KiroCLIOutputPlugin.ts +++ /dev/null @@ -1,459 +0,0 @@ -import type { - FastCommandPrompt, - OutputPluginContext, - OutputWriteContext, - Project, - ProjectChildrenMemoryPrompt, - RegistryOperationResult, - RulePrompt, - SkillPrompt, - SkillYAMLFrontMatter, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' -import {KiroPowersRegistryWriter} from './KiroPowersRegistryWriter' - -const GLOBAL_MEMORY_FILE = 'GLOBAL.md' -const GLOBAL_CONFIG_DIR = '.kiro' -const STEERING_SUBDIR = 'steering' -const SETTINGS_SUBDIR = 'settings' -const MCP_CONFIG_FILE = 'mcp.json' -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, indexignore: '.kiroignore'}) - - this.registerCleanEffect('registry-cleanup', async ctx => { - const writer = this.getRegistryWriter(KiroPowersRegistryWriter) - const success = writer.unregisterLocalPowers(ctx.dryRun) - if (success) return {success: true, description: 'Reset registry'} - return {success: false, description: 'Failed', error: new Error('Registry cleanup failed')} - }) - - this.registerCleanEffect('mcp-settings-cleanup', async ctx => { - const mcpPath = this.joinPath(this.getGlobalSettingsDir(), MCP_CONFIG_FILE) - const content = JSON.stringify({mcpServers: {}, powers: {mcpServers: {}}}, null, 2) - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'mcpSettingsCleanup', path: mcpPath}) - return {success: true, description: 'Would reset mcp.json'} - } - const result = await this.writeFile(ctx, mcpPath, content, 'mcpSettingsCleanup') - if (result.success) return {success: true, description: 'Reset mcp.json'} - return {success: false, description: 'Failed', error: result.error ?? new Error('Cleanup failed')} - }) - } - - private getGlobalSettingsDir(): string { - return this.joinPath(this.getHomeDir(), GLOBAL_CONFIG_DIR, SETTINGS_SUBDIR) - } - - private getGlobalSteeringDir(): string { - return this.joinPath(this.getGlobalConfigDir(), STEERING_SUBDIR) - } - - private getKiroPowersDir(): string { - return this.joinPath(this.getHomeDir(), KIRO_POWERS_DIR) - } - - private getKiroSkillsDir(): string { - return this.joinPath(this.getHomeDir(), KIRO_SKILLS_DIR) - } - - 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, STEERING_SUBDIR), - p.dirFromWorkspacePath!.basePath, - () => STEERING_SUBDIR - )) - } - - 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) 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 - )) - } - } - - if (rules != null && rules.length > 0) { - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => r.scope === 'project'), - project.projectConfig - ), - project.projectConfig - ) - 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 - } - - async registerGlobalOutputDirs(): Promise { - const results: RelativePath[] = [ - this.createRelativePath(STEERING_SUBDIR, this.getGlobalConfigDir(), () => STEERING_SUBDIR) - ] - - const powersDir = this.getKiroPowersDir() - for (const powerName of this.listInstalledPowers(powersDir)) results.push(this.createRelativePath(powerName, powersDir, () => powerName)) - - const skillsDir = this.getKiroSkillsDir() - for (const skillName of this.listInstalledPowers(skillsDir)) results.push(this.createRelativePath(skillName, skillsDir, () => skillName)) - - results.push(this.createRelativePath('repos', this.joinPath(this.getHomeDir(), '.kiro/powers'), () => 'repos')) - return results - } - - private listInstalledPowers(powersDir: string): string[] { - try { - if (!this.existsSync(powersDir)) return [] - return this.readdirSync(powersDir, {withFileTypes: true}).filter(e => e.isDirectory()).map(e => e.name) - } catch { - return [] - } - } - - async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const {globalMemory, fastCommands, skills, rules} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const steeringDir = this.getGlobalSteeringDir() - const results: RelativePath[] = [] - - if (globalMemory != null) results.push(this.createRelativePath(GLOBAL_MEMORY_FILE, steeringDir, () => STEERING_SUBDIR)) - - if (fastCommands != null) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) 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)) - } - - const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] - if (filteredSkills.length === 0) return results - - const powersDir = this.getKiroPowersDir() - const skillsDir = this.getKiroSkillsDir() - - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter.name - const hasMcp = skill.mcpConfig != null - - if (hasMcp) { - const skillDir = this.joinPath(powersDir, skillName) - results.push(this.createRelativePath(POWER_FILE_NAME, skillDir, () => skillName)) - results.push(this.createRelativePath(MCP_CONFIG_FILE, skillDir, () => skillName)) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - results.push(this.createRelativePath( - this.joinPath(STEERING_SUBDIR, refDoc.dir.path.replace(/\.mdx$/, '.md')), - skillDir, - () => STEERING_SUBDIR - )) - } - } - if (skill.resources != null) { - for (const res of skill.resources) results.push(this.createRelativePath(this.joinPath(STEERING_SUBDIR, res.relativePath), skillDir, () => STEERING_SUBDIR)) - } - } else { - const skillDir = this.joinPath(skillsDir, skillName) - results.push(this.createRelativePath(SKILL_FILE_NAME, skillDir, () => skillName)) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - results.push(this.createRelativePath( - refDoc.dir.path.replace(/\.mdx$/, '.md'), - skillDir, - () => skillName - )) - } - } - if (skill.resources != null) { - for (const res of skill.resources) results.push(this.createRelativePath(res.relativePath, skillDir, () => skillName)) - } - } - } - if (filteredSkills.some(s => s.mcpConfig != null)) results.push(this.createRelativePath(MCP_CONFIG_FILE, this.getGlobalSettingsDir(), () => SETTINGS_SUBDIR)) - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, globalMemory, fastCommands, skills, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext - const hasChildPrompts = workspace.projects.some(p => (p.childMemoryPrompts?.length ?? 0) > 0) - const hasRules = (rules?.length ?? 0) > 0 - const hasKiroIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.kiroignore') ?? false - - if (hasChildPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0 || 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) continue - - if (project.childMemoryPrompts != null) { - for (const child of project.childMemoryPrompts) fileResults.push(await this.writeSteeringFile(ctx, project, child)) - } - - if (rules != null && rules.length > 0) { - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => r.scope === 'project'), - project.projectConfig - ), - project.projectConfig - ) - 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, rules} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const fileResults: WriteResult[] = [] - const registryResults: RegistryOperationResult[] = [] - 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) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) 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')) - } - } - - const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] - if (filteredSkills.length === 0) return {files: fileResults, dirs: []} - - const powerSkills = filteredSkills.filter(s => s.mcpConfig != null) - const plainSkills = filteredSkills.filter(s => s.mcpConfig == null) - - for (const skill of powerSkills) { - const {fileResults: skillFiles, registryResult} = await this.writeSkillAsPower(ctx, skill) - fileResults.push(...skillFiles) - registryResults.push(registryResult) - } - - for (const skill of plainSkills) { - const skillFiles = await this.writeSkillAsKiroSkill(ctx, skill) - fileResults.push(...skillFiles) - } - - const mcpResult = await this.writeGlobalMcpSettings(ctx, filteredSkills) - if (mcpResult != null) fileResults.push(mcpResult) - this.logRegistryResults(registryResults, ctx.dryRun) - return {files: fileResults, dirs: []} - } - - private async writeGlobalMcpSettings(ctx: OutputWriteContext, skills: readonly SkillPrompt[]): Promise { - const powersMcpServers: Record = {} - for (const skill of skills) { - if (skill.mcpConfig == null) continue - for (const [mcpName, config] of Object.entries(skill.mcpConfig.mcpServers)) powersMcpServers[`power-${skill.yamlFrontMatter.name}-${mcpName}`] = config - } - if (Object.keys(powersMcpServers).length === 0) return null - - const content = JSON.stringify({mcpServers: {}, powers: {mcpServers: powersMcpServers}}, null, 2) - return this.writeFile(ctx, this.joinPath(this.getGlobalSettingsDir(), MCP_CONFIG_FILE), content, 'globalMcpSettings') - } - - private logRegistryResults(results: readonly RegistryOperationResult[], dryRun?: boolean): void { - const success = results.filter(r => r.success).length - const fail = results.filter(r => !r.success).length - if (success > 0) this.log.trace({action: dryRun === true ? 'dryRun' : 'register', type: 'registrySummary', successCount: success}) - if (fail > 0) this.log.error({action: 'register', type: 'registrySummary', failCount: fail}) - } - - private async writeSkillAsPower(ctx: OutputWriteContext, skill: SkillPrompt): Promise<{fileResults: WriteResult[], registryResult: RegistryOperationResult}> { - const fileResults: WriteResult[] = [] - const skillName = skill.yamlFrontMatter.name - const powerDir = this.joinPath(this.getKiroPowersDir(), skillName) - const powerFilePath = this.joinPath(powerDir, POWER_FILE_NAME) - - const fmStr = this.buildPowerFrontMatter(skill.yamlFrontMatter) - const powerContent = `${fmStr}\n${skill.content as string}` - fileResults.push(await this.writeFile(ctx, powerFilePath, powerContent, 'skillPower')) - - if (skill.childDocs != null) { - const steeringDir = this.joinPath(powerDir, STEERING_SUBDIR) - for (const refDoc of skill.childDocs) { - const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') - fileResults.push(await this.writeFile(ctx, this.joinPath(steeringDir, fileName), refDoc.content as string, 'refDoc')) - } - } - - if (skill.resources != null) { - const steeringDir = this.joinPath(powerDir, STEERING_SUBDIR) - for (const res of skill.resources) fileResults.push(await this.writeFile(ctx, this.joinPath(steeringDir, res.relativePath), res.content, 'resource')) - } - - if (skill.mcpConfig != null) fileResults.push(await this.writeFile(ctx, this.joinPath(powerDir, MCP_CONFIG_FILE), skill.mcpConfig.rawContent, 'mcpConfig')) - - const writer = this.getRegistryWriter(KiroPowersRegistryWriter) - const powerEntry = writer.buildPowerEntry(skill, powerDir) - const regResults = await this.registerInRegistry(writer, [powerEntry], ctx) - const registryResult = regResults[0] ?? {success: false, entryName: skillName, error: new Error('No registry result')} - - return {fileResults, registryResult} - } - - private async writeSkillAsKiroSkill(ctx: OutputWriteContext, skill: SkillPrompt): Promise { - const fileResults: WriteResult[] = [] - const skillName = skill.yamlFrontMatter.name - const skillDir = this.joinPath(this.getKiroSkillsDir(), skillName) - const skillFilePath = this.joinPath(skillDir, SKILL_FILE_NAME) - - const fmStr = this.buildSkillFrontMatter(skill.yamlFrontMatter) - const skillContent = `${fmStr}\n${skill.content as string}` - fileResults.push(await this.writeFile(ctx, skillFilePath, skillContent, 'kiroSkill')) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') - fileResults.push(await this.writeFile(ctx, this.joinPath(skillDir, fileName), refDoc.content as string, 'refDoc')) - } - } - - if (skill.resources != null) { - for (const res of skill.resources) fileResults.push(await this.writeFile(ctx, this.joinPath(skillDir, res.relativePath), res.content, 'resource')) - } - - return fileResults - } - - private buildSkillFrontMatter(fm: SkillYAMLFrontMatter): string { - return this.buildMarkdownContent('', { - name: fm.name, - description: fm.description, - ...fm.displayName != null && {displayName: fm.displayName}, - ...fm.keywords != null && fm.keywords.length > 0 && {keywords: fm.keywords}, - ...fm.author != null && {author: fm.author} - }).trimEnd() - } - - private buildPowerFrontMatter(fm: SkillYAMLFrontMatter): string { - return this.buildMarkdownContent('', { - name: fm.name, - displayName: fm.displayName, - description: fm.description, - keywords: fm.keywords, - author: fm.author - }).trimEnd() - } - - 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 `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) - const targetDir = this.joinPath(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, STEERING_SUBDIR) - const fullPath = this.joinPath(targetDir, fileName) - - const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path - const content = this.buildMarkdownContent(child.content as string, { - inclusion: 'fileMatch', - fileMatchPattern: `${childPath.replaceAll('\\', '/')}/**` - }) - - return this.writeFile(ctx, fullPath, content, 'steeringFile') - } -} diff --git a/packages/plugin-kiro-ide/src/KiroPowers.integration.test.ts b/packages/plugin-kiro-ide/src/KiroPowers.integration.test.ts deleted file mode 100644 index 9513334e..00000000 --- a/packages/plugin-kiro-ide/src/KiroPowers.integration.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type {SkillPrompt, SkillReferenceDocument, SkillYAMLFrontMatter} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {afterEach, beforeEach, describe, expect, it} from 'vitest' - -/** - * Integration tests for Kiro Powers Skill Output - * Tests the round-trip property and co-location of reference documents - */ -describe('kiroPowersIntegration', () => { - let tempDir: string - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-powers-test-')) // Create a temporary directory for test files - }) - - afterEach(() => { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Clean up temporary directory - }) - - describe('property 4: Reference Document Round-Trip', () => { - const markdownContentGen = fc.array( // Generator for valid markdown content (without front matter for simplicity) - fc.oneof( - fc.constant('# Heading\n'), - fc.constant('## Subheading\n'), - fc.constant('- List item\n'), - fc.constant('* Bullet point\n'), - fc.constant('\n'), - fc.constant('Some text content.\n'), - fc.constant('Another paragraph.\n'), - fc.constant('Code: `example`\n') - ), - {minLength: 1, maxLength: 10} - ).map(parts => parts.join('').trim() || 'Default content') - - const fileNameGen = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generator for valid file names (alphanumeric with .md extension) - .filter(s => /^[a-z0-9]+$/i.test(s)) - .map(s => `${s}.md`) - - it('should preserve content when reading and writing reference documents', () => { - fc.assert( - fc.property( - markdownContentGen, - fileNameGen, - (content, fileName) => { - const refDocPath = path.join(tempDir, fileName) // Arrange: Create a reference document file - fs.writeFileSync(refDocPath, content, 'utf8') - - const parsed = parseMarkdown(content) // Act: Parse the content using parseMarkdown (same as SkillInputPlugin) - const parsedContent = parsed.contentWithoutFrontMatter - - const outputPath = path.join(tempDir, `output-${fileName}`) // Simulate writing via KiroCLIOutputPlugin (writes content without front matter) - fs.writeFileSync(outputPath, parsedContent, 'utf8') - - const readBackContent = fs.readFileSync(outputPath, 'utf8') // Assert: Read back and verify content is identical - expect(readBackContent).toBe(parsedContent) - } - ), - {numRuns: 100} - ) - }) - - it('should preserve content with front matter when round-tripping', () => { - fc.assert( - fc.property( - markdownContentGen, - fileNameGen, - (bodyContent, fileName) => { - const frontMatter = '---\ntitle: Test Document\n---\n' // Arrange: Create content with front matter - const fullContent = `${frontMatter}${bodyContent}` - const refDocPath = path.join(tempDir, fileName) - fs.writeFileSync(refDocPath, fullContent, 'utf8') - - const parsed = parseMarkdown(fullContent) // Act: Parse the content - const {contentWithoutFrontMatter} = parsed - - const outputPath = path.join(tempDir, `output-${fileName}`) // Write the content without front matter (as KiroCLIOutputPlugin does) - fs.writeFileSync(outputPath, contentWithoutFrontMatter, 'utf8') - - const readBackContent = fs.readFileSync(outputPath, 'utf8') // Assert: Content without front matter should match body content - expect(readBackContent).toBe(bodyContent) - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 6: Reference Document Co-location', () => { - const skillNameGen = fc.string({minLength: 1, maxLength: 15, unit: 'grapheme-ascii'}) // Generator for valid skill names (alphanumeric, kebab-case friendly) - .filter(s => /^[a-z0-9]+$/i.test(s)) - .map(s => s || 'default-skill') - - const refDocFileNameGen = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) // Generator for reference document file names - .filter(s => /^[a-z0-9]+$/i.test(s)) - .map(s => `${s || 'doc'}.md`) - - it('should place all reference documents in the same directory as POWER.md', () => { - fc.assert( - fc.property( - skillNameGen, - fc.array(refDocFileNameGen, {minLength: 0, maxLength: 5}), - (skillName, refDocFileNames) => { - const uniqueFileNames = [...new Set(refDocFileNames)] // Ensure unique file names - - const referenceDocuments: SkillReferenceDocument[] = uniqueFileNames.map(fileName => ({ // Create mock reference documents - type: PromptKind.SkillReferenceDocument, - content: `Content of ${fileName}`, - length: `Content of ${fileName}`.length, - filePathKind: FilePathKind.Relative, - markdownContents: [], - dir: { - pathKind: FilePathKind.Relative, - path: fileName, - basePath: tempDir, - getDirectoryName: () => '', - getAbsolutePath: () => path.join(tempDir, fileName) - } - })) - - const _skill: SkillPrompt = { // Create mock skill prompt (prefixed with _ as it's used for documentation purposes) - type: PromptKind.Skill, - content: '# Skill Content', - length: 15, - filePathKind: FilePathKind.Relative, - yamlFrontMatter: { - name: skillName, - description: 'Test skill' - } as SkillYAMLFrontMatter, - markdownContents: [], - dir: { - pathKind: FilePathKind.Relative, - path: skillName, - basePath: tempDir, - getDirectoryName: () => skillName, - getAbsolutePath: () => path.join(tempDir, skillName) - }, - ...referenceDocuments.length > 0 && {referenceDocuments} - } - - const powersDir = path.join(os.homedir(), '.kiro/powers/installed') // Calculate expected paths - const skillPowerDir = path.join(powersDir, skillName) - const expectedPowerMdPath = path.join(skillPowerDir, 'POWER.md') - - for (const refDoc of referenceDocuments) { // Verify all reference documents would be in the same directory - const expectedRefDocPath = path.join(skillPowerDir, refDoc.dir.path) - const refDocDir = path.dirname(expectedRefDocPath) - const powerMdDir = path.dirname(expectedPowerMdPath) - - expect(refDocDir).toBe(powerMdDir) // Assert: Reference document directory should equal POWER.md directory - } - - return true - } - ), - {numRuns: 100} - ) - }) - - it('should maintain co-location for skills with varying numbers of reference documents', () => { - fc.assert( - fc.property( - skillNameGen, - fc.array(refDocFileNameGen, {minLength: 1, maxLength: 10}), - (skillName, refDocFileNames) => { - const uniqueFileNames = [...new Set(refDocFileNames)] // Ensure unique file names - if (uniqueFileNames.length === 0) return true - - const powersDir = path.join(os.homedir(), '.kiro/powers/installed') // Calculate expected base directory - const skillPowerDir = path.join(powersDir, skillName) - - const allPaths = [ // All files should be in the same directory - path.join(skillPowerDir, 'POWER.md'), - ...uniqueFileNames.map(fn => path.join(skillPowerDir, fn)) - ] - - const directories = allPaths.map(p => path.dirname(p)) - const uniqueDirs = [...new Set(directories)] - - expect(uniqueDirs.length).toBe(1) // Assert: All files should be in exactly one directory - expect(uniqueDirs[0]).toBe(skillPowerDir) - - return true - } - ), - {numRuns: 100} - ) - }) - }) -}) diff --git a/packages/plugin-kiro-ide/src/KiroPowersRegistryWriter.property.test.ts b/packages/plugin-kiro-ide/src/KiroPowersRegistryWriter.property.test.ts deleted file mode 100644 index a76897f2..00000000 --- a/packages/plugin-kiro-ide/src/KiroPowersRegistryWriter.property.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Property-based tests for KiroPowersRegistryWriter - * - * Feature: plugin-side-effects - * Property 6: Registry Reset to Official State - * - * After executing the clean effect, the registry shall be reset to the official - * Kiro powers registry state (from build-time constant or empty fallback). - * - * Validates: Requirements 6.1, 6.2 - */ - -import type { - KiroPowerEntry, - KiroPowersRegistry, - KiroRepoSource -} from '@truenine/plugin-shared' - -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' - -import * as fc from 'fast-check' -import {afterEach, beforeEach, describe, expect, it} from 'vitest' - -import {KiroPowersRegistryWriter} from './KiroPowersRegistryWriter' - -/** - * Test subclass that allows setting a custom registry path for testing. - */ -class TestableKiroPowersRegistryWriter extends KiroPowersRegistryWriter { - private readonly testRegistryPath: string - - constructor(testRegistryPath: string) { - super() - this.testRegistryPath = testRegistryPath - ; (this as any).registryPath = testRegistryPath // Override the registry path using reflection - } -} - -/** - * Generators for property-based testing - */ - -const powerNameGen = fc.string({minLength: 1, maxLength: 30, unit: 'grapheme-ascii'}) // Generator for valid power names (alphanumeric with hyphens) - .filter(s => /^[a-z][a-z0-9-]*$/i.test(s)) - -const descriptionGen = fc.string({minLength: 1, maxLength: 100}) // Generator for descriptions - -const keywordsGen = fc.array( // Generator for keywords - fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}).filter(s => /^[a-z0-9]+$/i.test(s)), - {minLength: 0, maxLength: 5} -) - -const validDateGen = fc.integer({ // Using integer timestamps to ensure valid dates // Generator for valid dates (constrained to avoid invalid date values) - min: new Date('2020-01-01').getTime(), - max: new Date('2030-12-31').getTime() -}).map(timestamp => new Date(timestamp)) - -const localPowerEntryGen = fc.record({ // Generator for local power entries (source.repoId starts with 'local-') - name: powerNameGen, - description: descriptionGen, - keywords: keywordsGen, - installed: fc.constant(true), - installedAt: validDateGen.map(d => d.toISOString()), - installPath: fc.string({minLength: 1, maxLength: 50}).map(s => `/test/path/${s}`), - source: fc.record({ - type: fc.constant('repo' as const), - repoId: fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z0-9]+$/i.test(s)) - .map(s => `local-${s}`), // Ensure repoId starts with 'local-' - repoName: fc.string({minLength: 1, maxLength: 30}) - }), - sourcePath: fc.string({minLength: 1, maxLength: 50}).map(s => `/test/source/${s}`) -}) as fc.Arbitrary - -const localRepoSourceGen = fc.record({ - name: fc.string({minLength: 1, maxLength: 50}), - type: fc.constant('local' as const), - enabled: fc.boolean(), - addedAt: validDateGen.map(d => d.toISOString()), - powerCount: fc.nat({max: 10}), - path: fc.string({minLength: 1, maxLength: 50}).map(s => `/test/${s}`), - lastSync: validDateGen.map(d => d.toISOString()) -}) as fc.Arbitrary - -const repoSourceIdGen = fc.string({minLength: 1, maxLength: 30, unit: 'grapheme-ascii'}) // Generator for repoSource ID - .filter(s => /^[a-z0-9-]+$/i.test(s)) - -describe('kiroPowersRegistryWriter Property Tests', () => { - let tempDir: string, - registryPath: string - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-registry-test-')) // Create a unique temp directory for each test - registryPath = path.join(tempDir, 'registry.json') - }) - - afterEach(() => { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) // Clean up temp directory - }) - - describe('property 6: Registry Reset to Official State', () => { - it('should reset registry to official state after cleanup (empty in tests)', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(localPowerEntryGen, {minLength: 1, maxLength: 5}), // Generate 0-5 local powers - async localPowers => { - const uniqueLocalPowers = localPowers.map((p, i) => ({ // Ensure unique names by adding index suffix - ...p, - name: `${p.name}-local-${i}` - })) - - const powers: Record = {} // Build initial registry with local powers - for (const power of uniqueLocalPowers) powers[power.name] = power - - const initialRegistry: KiroPowersRegistry = { - version: '1.0.0', - powers, - repoSources: {}, - lastUpdated: new Date().toISOString() - } - - fs.writeFileSync(registryPath, JSON.stringify(initialRegistry, null, 2)) // Write initial registry to disk - - const writer = new TestableKiroPowersRegistryWriter(registryPath) // Create writer and execute cleanup - const result = writer.unregisterLocalPowers(false) - - expect(result).toBe(true) - - const cleanedRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as KiroPowersRegistry // Read cleaned registry - - expect(cleanedRegistry.version).toBe('1.0.0') // since __KIRO_GLOBAL_POWERS_REGISTRY__ is not defined // In test environment, should reset to empty registry (fallback) - expect(Object.keys(cleanedRegistry.powers).length).toBe(0) - expect(Object.keys(cleanedRegistry.repoSources).length).toBe(0) - expect(cleanedRegistry.lastUpdated).toBeDefined() - } - ), - {numRuns: 50} - ) - }) - - it('should reset registry and clear all repoSources after cleanup', async () => { - await fc.assert( - fc.asyncProperty( - fc.array( // Generate 0-5 local repoSources - fc.tuple(repoSourceIdGen, localRepoSourceGen), - {minLength: 1, maxLength: 5} - ), - async localSources => { - const uniqueLocalSources = localSources.map(([id, source], i) => [ // Ensure unique IDs by adding suffix - `${id}-local-${i}`, - source - ] as const) - - const repoSources: Record = {} // Build initial registry with local repoSources - for (const [id, source] of uniqueLocalSources) repoSources[id] = source - - const initialRegistry: KiroPowersRegistry = { - version: '1.0.0', - powers: {}, - repoSources, - lastUpdated: new Date().toISOString() - } - - fs.writeFileSync(registryPath, JSON.stringify(initialRegistry, null, 2)) // Write initial registry to disk - - const writer = new TestableKiroPowersRegistryWriter(registryPath) // Create writer and execute cleanup - const result = writer.unregisterLocalPowers(false) - - expect(result).toBe(true) - - const cleanedRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as KiroPowersRegistry // Read cleaned registry - - expect(Object.keys(cleanedRegistry.repoSources).length).toBe(0) // All repoSources should be cleared (reset to official state) - } - ), - {numRuns: 50} - ) - }) - - it('should preserve registry structure after cleanup', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(localPowerEntryGen, {minLength: 1, maxLength: 3}), - async localPowers => { - const uniqueLocalPowers = localPowers.map((p, i) => ({ // Ensure unique names - ...p, - name: `${p.name}-${i}` - })) - - const powers: Record = {} - for (const power of uniqueLocalPowers) powers[power.name] = power - - const initialRegistry: KiroPowersRegistry = { - version: '2.0.0', - powers, - repoSources: {}, - kiroRecommendedRepo: { - url: 'https://example.com/repo', - lastFetch: new Date().toISOString(), - powerCount: 42 - }, - lastUpdated: new Date().toISOString() - } - - fs.writeFileSync(registryPath, JSON.stringify(initialRegistry, null, 2)) // Write and cleanup - const writer = new TestableKiroPowersRegistryWriter(registryPath) - writer.unregisterLocalPowers(false) - - const cleanedRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as KiroPowersRegistry // Read cleaned registry - - expect(cleanedRegistry.version).toBeDefined() // Verify structure is valid (reset to official state) - expect(cleanedRegistry.powers).toBeDefined() - expect(cleanedRegistry.repoSources).toBeDefined() - expect(cleanedRegistry.lastUpdated).toBeDefined() - } - ), - {numRuns: 50} - ) - }) - - it('should succeed when registry file does not exist', async () => { - if (fs.existsSync(registryPath)) fs.unlinkSync(registryPath) // Ensure registry file does not exist - - const writer = new TestableKiroPowersRegistryWriter(registryPath) - const result = writer.unregisterLocalPowers(false) - - expect(result).toBe(true) // Should succeed without error (Requirement 6.4) - - expect(fs.existsSync(registryPath)).toBe(true) // Registry file should be created with official state - const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as KiroPowersRegistry - expect(registry.version).toBe('1.0.0') - }) - - it('should not modify registry in dry-run mode', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(localPowerEntryGen, {minLength: 1, maxLength: 3}), - async localPowers => { - const uniqueLocalPowers = localPowers.map((p, i) => ({ // Ensure unique names - ...p, - name: `${p.name}-${i}` - })) - - const powers: Record = {} - for (const power of uniqueLocalPowers) powers[power.name] = power - - const initialRegistry: KiroPowersRegistry = { - version: '1.0.0', - powers, - repoSources: {}, - lastUpdated: new Date().toISOString() - } - - fs.writeFileSync(registryPath, JSON.stringify(initialRegistry, null, 2)) // Write initial registry - const originalContent = fs.readFileSync(registryPath, 'utf8') - - const writer = new TestableKiroPowersRegistryWriter(registryPath) // Execute cleanup in dry-run mode - const result = writer.unregisterLocalPowers(true) // dry-run = true - - expect(result).toBe(true) - - const afterContent = fs.readFileSync(registryPath, 'utf8') // Verify file was not modified - expect(afterContent).toBe(originalContent) - } - ), - {numRuns: 50} - ) - }) - }) -}) diff --git a/packages/plugin-kiro-ide/src/KiroPowersRegistryWriter.ts b/packages/plugin-kiro-ide/src/KiroPowersRegistryWriter.ts deleted file mode 100644 index 57faa049..00000000 --- a/packages/plugin-kiro-ide/src/KiroPowersRegistryWriter.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Kiro Powers Registry Writer - * - * Concrete implementation of RegistryWriter for managing Kiro's powers registry. - * Manages ~/.kiro/powers/registry.json file. - * - * @see Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8 - */ - -import type {ILogger} from '@truenine/plugin-shared' -import type {KiroPowerEntry, KiroPowerSource, KiroPowersRegistry, KiroRepoSource, SkillPrompt} from '@truenine/plugin-shared/types' - -import {RegistryWriter} from '@truenine/plugin-output-shared/registry' - -/** - * Registry writer for Kiro powers. - * Manages ~/.kiro/powers/registry.json file. - * - * @see Requirements 4.1, 4.2 - */ -export class KiroPowersRegistryWriter extends RegistryWriter { - private static readonly REGISTRY_PATH = '~/.kiro/powers/registry.json' - - private static readonly DEFAULT_VERSION = '1.0.0' - - constructor(logger?: ILogger) { - super(KiroPowersRegistryWriter.REGISTRY_PATH, logger) - } - - protected createInitialRegistry(): KiroPowersRegistry { - return { - version: KiroPowersRegistryWriter.DEFAULT_VERSION, - powers: {}, - repoSources: {}, - lastUpdated: new Date().toISOString() - } - } - - protected getEntryName(entry: KiroPowerEntry): string { - return entry.name - } - - protected merge( - existing: KiroPowersRegistry, - entries: readonly KiroPowerEntry[] - ): KiroPowersRegistry { - const powers = {...existing.powers} - const repoSources = {...existing.repoSources} - - for (const entry of entries) { - powers[entry.name] = entry - - const repoSource = this.buildRepoSource(entry) - const repoId = entry.source.repoId ?? entry.name - repoSources[repoId] = repoSource - } - - return { - version: existing.version, - powers, - repoSources, - ...existing.kiroRecommendedRepo != null && { - kiroRecommendedRepo: existing.kiroRecommendedRepo - }, - lastUpdated: existing.lastUpdated - } - } - - buildPowerEntry(skill: SkillPrompt, installPath: string): KiroPowerEntry { - const {yamlFrontMatter, mcpConfig} = skill - const repoId = this.generateEntryId('local') - - const source: KiroPowerSource = { - type: 'repo', - repoId, - repoName: installPath - } - - const mcpServerNames = mcpConfig != null - ? Object.keys(mcpConfig.mcpServers) - : null - - return { - name: yamlFrontMatter.name, - description: yamlFrontMatter.description, - ...mcpServerNames != null && mcpServerNames.length > 0 && {mcpServers: mcpServerNames}, - ...yamlFrontMatter.author != null && {author: yamlFrontMatter.author}, - keywords: yamlFrontMatter.keywords ?? [], - ...yamlFrontMatter.displayName != null && {displayName: yamlFrontMatter.displayName}, - installed: true, - installedAt: new Date().toISOString(), - installPath, - source, - sourcePath: installPath - } - } - - private getOfficialRegistry(): KiroPowersRegistry { - try { - if (typeof __KIRO_GLOBAL_POWERS_REGISTRY__ !== 'undefined') return JSON.parse(__KIRO_GLOBAL_POWERS_REGISTRY__) as KiroPowersRegistry - } - catch { - this.log.debug('Failed to parse official registry, using empty registry') - } - return this.createInitialRegistry() - } - - unregisterLocalPowers(dryRun?: boolean): boolean { - const officialRegistry = this.getOfficialRegistry() - - const resetRegistry: KiroPowersRegistry = { - ...officialRegistry, - lastUpdated: new Date().toISOString() - } - - this.log.trace({action: dryRun === true ? 'dryRun' : 'reset', type: 'registry', powerCount: Object.keys(resetRegistry.powers).length}) - - return this.write(resetRegistry, dryRun) - } - - private buildRepoSource(power: KiroPowerEntry): KiroRepoSource { - const now = new Date().toISOString() - - return { - name: power.sourcePath ?? power.installPath ?? power.name, - type: 'local', - enabled: true, - addedAt: now, - powerCount: 1, - ...power.sourcePath != null && {path: power.sourcePath}, - lastSync: now - } - } -} diff --git a/packages/plugin-kiro-ide/src/index.ts b/packages/plugin-kiro-ide/src/index.ts deleted file mode 100644 index 695ca2cf..00000000 --- a/packages/plugin-kiro-ide/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - KiroCLIOutputPlugin -} from './KiroCLIOutputPlugin' -export { - KiroPowersRegistryWriter -} from './KiroPowersRegistryWriter' diff --git a/packages/plugin-kiro-ide/tsconfig.eslint.json b/packages/plugin-kiro-ide/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-kiro-ide/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-kiro-ide/tsconfig.json b/packages/plugin-kiro-ide/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-kiro-ide/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-kiro-ide/tsconfig.lib.json b/packages/plugin-kiro-ide/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-kiro-ide/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-kiro-ide/tsconfig.test.json b/packages/plugin-kiro-ide/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-kiro-ide/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-kiro-ide/tsdown.config.ts b/packages/plugin-kiro-ide/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-kiro-ide/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-kiro-ide/vite.config.ts b/packages/plugin-kiro-ide/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-kiro-ide/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-kiro-ide/vitest.config.ts b/packages/plugin-kiro-ide/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-kiro-ide/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-openai-codex-cli/eslint.config.ts b/packages/plugin-openai-codex-cli/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-openai-codex-cli/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-openai-codex-cli/package.json b/packages/plugin-openai-codex-cli/package.json deleted file mode 100644 index c8267ad9..00000000 --- a/packages/plugin-openai-codex-cli/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@truenine/plugin-openai-codex-cli", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "OpenAI Codex CLI output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-openai-codex-cli/src/CodexCLIOutputPlugin.ts b/packages/plugin-openai-codex-cli/src/CodexCLIOutputPlugin.ts deleted file mode 100644 index 99b4c210..00000000 --- a/packages/plugin-openai-codex-cli/src/CodexCLIOutputPlugin.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { - FastCommandPrompt, - OutputPluginContext, - OutputWriteContext, - SkillPrompt, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import * as path from 'node:path' -import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' -import {PLUGIN_NAMES} from '@truenine/plugin-shared' - -const PROJECT_MEMORY_FILE = 'AGENTS.md' -const GLOBAL_CONFIG_DIR = '.codex' -const PROMPTS_SUBDIR = 'prompts' -const SKILLS_SUBDIR = 'skills' -const SKILL_FILE_NAME = 'SKILL.md' - -export class CodexCLIOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('CodexCLIOutputPlugin', { - globalConfigDir: GLOBAL_CONFIG_DIR, - outputFileName: PROJECT_MEMORY_FILE, - dependsOn: [PLUGIN_NAMES.AgentsOutput] - }) - } - - async registerProjectOutputDirs(): Promise { - return [] // Codex only supports global prompts and skills - } - - async registerProjectOutputFiles(): Promise { - return [] // AGENTS.md files are handled by AgentsOutputPlugin - } - - async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { - const globalDir = this.getGlobalConfigDir() - const results: RelativePath[] = [ - this.createRelativePath(PROMPTS_SUBDIR, globalDir, () => PROMPTS_SUBDIR) - ] - - const {skills} = ctx.collectedInputContext - if (skills == null || skills.length === 0) return results - - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - results.push(this.createRelativePath( - path.join(SKILLS_SUBDIR, skillName), - globalDir, - () => skillName - )) - } - return results - } - - async registerGlobalOutputFiles(): Promise { - const globalDir = this.getGlobalConfigDir() - return [ - this.createRelativePath(PROJECT_MEMORY_FILE, globalDir, () => GLOBAL_CONFIG_DIR) - ] - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands, skills} = ctx.collectedInputContext - if (globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0) return true - this.log.trace({action: 'skip', reason: 'noOutputs'}) - return false - } - - async writeProjectOutputs(): Promise { - return {files: [], dirs: []} // Handled by AgentsOutputPlugin - } - - async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands, skills} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const fileResults: WriteResult[] = [] - const globalDir = this.getGlobalConfigDir() - - if (globalMemory != null) { - const fullPath = path.join(globalDir, PROJECT_MEMORY_FILE) - const result = await this.writeFile(ctx, fullPath, globalMemory.content as string, 'globalMemory') - fileResults.push(result) - } - - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) { - const result = await this.writeGlobalFastCommand(ctx, globalDir, cmd) - fileResults.push(result) - } - } - - if (skills == null || skills.length === 0) return {files: fileResults, dirs: []} - - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - for (const skill of filteredSkills) { - const skillResults = await this.writeGlobalSkill(ctx, globalDir, skill) - fileResults.push(...skillResults) - } - return {files: fileResults, dirs: []} - } - - private async writeGlobalFastCommand( - ctx: OutputWriteContext, - globalDir: string, - cmd: FastCommandPrompt - ): Promise { - const transformOptions = this.getTransformOptionsFromContext(ctx) - const fileName = this.transformFastCommandName(cmd, transformOptions) - const fullPath = path.join(globalDir, PROMPTS_SUBDIR, fileName) - const content = this.buildMarkdownContentWithRaw(cmd.content, cmd.yamlFrontMatter, cmd.rawFrontMatter) - return this.writeFile(ctx, fullPath, content, 'globalFastCommand') - } - - private async writeGlobalSkill( - ctx: OutputWriteContext, - globalDir: string, - skill: SkillPrompt - ): Promise { - const results: WriteResult[] = [] - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - const skillDir = path.join(globalDir, SKILLS_SUBDIR, skillName) - const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) - - const content = this.buildCodexSkillContent(skill) - const mainResult = await this.writeFile(ctx, skillFilePath, content, 'globalSkill') - results.push(mainResult) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') - const fullPath = path.join(skillDir, fileName) - const refResult = await this.writeFile(ctx, fullPath, refDoc.content as string, 'skillRefDoc') - results.push(refResult) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) { - const fullPath = path.join(skillDir, resource.relativePath) - const resourceResult = await this.writeFile(ctx, fullPath, resource.content, 'skillResource') - results.push(resourceResult) - } - } - - return results - } - - private buildCodexSkillContent(skill: SkillPrompt): string { - const fm = skill.yamlFrontMatter - const name = this.normalizeSkillName(fm.name, 64) - const description = this.normalizeToSingleLine(fm.description, 1024) - - const metadata: Record = {} - if (fm.displayName != null) metadata['short-description'] = fm.displayName - if (fm.version != null) metadata['version'] = fm.version - if (fm.author != null) metadata['author'] = fm.author - if (fm.keywords != null && fm.keywords.length > 0) metadata['keywords'] = [...fm.keywords] - - const fmData: Record = {name, description} - if (Object.keys(metadata).length > 0) fmData['metadata'] = metadata - if (fm.allowTools != null && fm.allowTools.length > 0) fmData['allowed-tools'] = fm.allowTools.join(' ') - - return buildMarkdownWithFrontMatter(fmData, skill.content as string) - } - - private normalizeSkillName(name: string, maxLength: number): string { - let normalized = name - .toLowerCase() - .replaceAll(/[^a-z0-9-]/g, '-') - .replaceAll(/-+/g, '-') - .replaceAll(/^-+|-+$/g, '') - - if (normalized.length > maxLength) normalized = normalized.slice(0, maxLength).replace(/-+$/, '') - return normalized - } - - private normalizeToSingleLine(text: string, maxLength: number): string { - const singleLine = text.replaceAll(/[\r\n]+/g, ' ').replaceAll(/\s+/g, ' ').trim() - if (singleLine.length > maxLength) return `${singleLine.slice(0, maxLength - 3)}...` - return singleLine - } -} diff --git a/packages/plugin-openai-codex-cli/src/index.ts b/packages/plugin-openai-codex-cli/src/index.ts deleted file mode 100644 index f1affd58..00000000 --- a/packages/plugin-openai-codex-cli/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - CodexCLIOutputPlugin -} from './CodexCLIOutputPlugin' diff --git a/packages/plugin-openai-codex-cli/tsconfig.eslint.json b/packages/plugin-openai-codex-cli/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-openai-codex-cli/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-openai-codex-cli/tsconfig.json b/packages/plugin-openai-codex-cli/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-openai-codex-cli/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-openai-codex-cli/tsconfig.lib.json b/packages/plugin-openai-codex-cli/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-openai-codex-cli/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-openai-codex-cli/tsconfig.test.json b/packages/plugin-openai-codex-cli/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-openai-codex-cli/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-openai-codex-cli/tsdown.config.ts b/packages/plugin-openai-codex-cli/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-openai-codex-cli/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-openai-codex-cli/vite.config.ts b/packages/plugin-openai-codex-cli/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-openai-codex-cli/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-openai-codex-cli/vitest.config.ts b/packages/plugin-openai-codex-cli/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-openai-codex-cli/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-opencode-cli/eslint.config.ts b/packages/plugin-opencode-cli/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-opencode-cli/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-opencode-cli/package.json b/packages/plugin-opencode-cli/package.json deleted file mode 100644 index 4617b630..00000000 --- a/packages/plugin-opencode-cli/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@truenine/plugin-opencode-cli", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Opencode CLI output plugin", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "jsonc-parser": "catalog:" - } -} diff --git a/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.projectConfig.test.ts b/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.projectConfig.test.ts deleted file mode 100644 index 135af5cf..00000000 --- a/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.projectConfig.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import type {OutputPluginContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {OpencodeCLIOutputPlugin} from './OpencodeCLIOutputPlugin' - -class TestableOpencodeCLIOutputPlugin extends OpencodeCLIOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -function createMockContext( - tempDir: string, - rules: unknown[], - projects: unknown[] -): OutputPluginContext { - return { - collectedInputContext: { - workspace: { - projects: projects as never, - directory: { - pathKind: 1, - path: tempDir, - basePath: tempDir, - getDirectoryName: () => 'workspace', - getAbsolutePath: () => tempDir - } - }, - ideConfigFiles: [], - rules: rules as never, - fastCommands: [], - skills: [], - globalMemory: void 0, - aiAgentIgnoreConfigFiles: [], - subAgents: [] - }, - logger: { - debug: vi.fn(), - trace: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } as never, - fs, - path, - glob: vi.fn() as never - } -} - -describe('opencodeCLIOutputPlugin - projectConfig filtering', () => { - let tempDir: string, - plugin: TestableOpencodeCLIOutputPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-proj-config-test-')) - plugin = new TestableOpencodeCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch {} - }) - - describe('registerProjectOutputFiles', () => { - it('should include all project rules when no projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [createMockProject('proj1', tempDir, 'proj1')] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).toContain('rule-test-rule2.md') - }) - - it('should filter rules by include in projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).not.toContain('rule-test-rule2.md') - }) - - it('should filter rules by includeSeries excluding non-matching series', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).not.toContain('rule-test-rule1.md') - expect(fileNames).toContain('rule-test-rule2.md') - }) - - it('should include rules without seriName regardless of include filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', void 0, 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).not.toContain('rule-test-rule2.md') - }) - - it('should filter independently for each project', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}), - createMockProject('proj2', tempDir, 'proj2', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = results.map(r => ({ - path: r.path, - fileName: r.path.split(/[/\\]/).pop() - })) - - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule1.md')).toBe(true) - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule2.md')).toBe(false) - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule2.md')).toBe(true) - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule1.md')).toBe(false) - }) - - it('should return empty when include matches nothing', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const ruleFiles = results.filter(r => r.path.includes('rule-')) - - expect(ruleFiles).toHaveLength(0) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should not register rules dir when all rules filtered out', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputDirs(ctx) - const rulesDirs = results.filter(r => r.path.includes('rules')) - - expect(rulesDirs).toHaveLength(0) - }) - - it('should register rules dir when rules match filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputDirs(ctx) - const rulesDirs = results.filter(r => r.path.includes('rules')) - - expect(rulesDirs.length).toBeGreaterThan(0) - }) - }) - - describe('project rules directory path', () => { - it('should use .opencode/rules/ for project rules', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [createMockProject('proj1', tempDir, 'proj1')] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const ruleFile = results.find(r => r.path.includes('rule-test-rule1.md')) - - expect(ruleFile).toBeDefined() - expect(ruleFile?.path).toContain('.opencode') - expect(ruleFile?.path).toContain('rules') - }) - }) -}) diff --git a/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.property.test.ts b/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.property.test.ts deleted file mode 100644 index 0ca383b5..00000000 --- a/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.property.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type {CollectedInputContext, OutputPluginContext, Project, RelativePath, RulePrompt} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {OpencodeCLIOutputPlugin} from './OpencodeCLIOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return {pathKind: FilePathKind.Relative, path: pathStr, basePath, getDirectoryName: () => pathStr, getAbsolutePath: () => path.join(basePath, pathStr)} -} - -class TestablePlugin extends OpencodeCLIOutputPlugin { - private mockHomeDir: string | null = null - public setMockHomeDir(dir: string | null): void { this.mockHomeDir = dir } - protected override getHomeDir(): string { return this.mockHomeDir ?? super.getHomeDir() } - public testBuildRuleFileName(rule: RulePrompt): string { return (this as any).buildRuleFileName(rule) } - public testBuildRuleContent(rule: RulePrompt): string { return (this as any).buildRuleContent(rule) } -} - -function createMockRulePrompt(opts: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { - const content = opts.content ?? '# Rule body' - return {type: PromptKind.Rule, content, length: content.length, filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', ''), markdownContents: [], yamlFrontMatter: {description: 'ignored', globs: opts.globs}, series: opts.series, ruleName: opts.ruleName, globs: opts.globs, scope: opts.scope ?? 'global'} as RulePrompt -} - -const seriesGen = fc.stringMatching(/^[a-z0-9]{1,5}$/) -const ruleNameGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,14}$/) -const globGen = fc.stringMatching(/^[a-z*/.]{1,30}$/).filter(s => s.length > 0) -const globsGen = fc.array(globGen, {minLength: 1, maxLength: 5}) -const contentGen = fc.string({minLength: 1, maxLength: 200}).filter(s => s.trim().length > 0) - -describe('opencodeCLIOutputPlugin property tests', () => { - let tempDir: string, plugin: TestablePlugin, mockContext: OutputPluginContext - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-prop-')) - plugin = new TestablePlugin() - plugin.setMockHomeDir(tempDir) - mockContext = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: {type: PromptKind.GlobalMemory, content: 'mem', filePathKind: FilePathKind.Absolute, dir: createMockRelativePath('.', tempDir), markdownContents: []}, - fastCommands: [], - subAgents: [], - skills: [] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - }, 30000) - - afterEach(() => { - try { fs.rmSync(tempDir, {recursive: true, force: true}) } - catch {} - }) - - describe('rule file name format', () => { - it('should always produce rule-{series}-{ruleName}.md', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, async (series, ruleName) => { - const rule = createMockRulePrompt({series, ruleName, globs: []}) - const fileName = plugin.testBuildRuleFileName(rule) - expect(fileName).toBe(`rule-${series}-${ruleName}.md`) - expect(fileName).toMatch(/^rule-.[^-\n\r\u2028\u2029]*-.+\.md$/) - }), {numRuns: 100}) - }) - }) - - describe('rule content format constraints', () => { - it('should never contain paths field in frontmatter', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).not.toMatch(/^paths:/m) - }), {numRuns: 100}) - }) - - it('should use globs field when globs are present', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).toContain('globs:') - }), {numRuns: 100}) - }) - - it('should wrap frontmatter in --- delimiters when globs exist', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - const lines = output.split('\n') - expect(lines[0]).toBe('---') - expect(lines.indexOf('---', 1)).toBeGreaterThan(0) - }), {numRuns: 100}) - }) - - it('should have no frontmatter when globs are empty', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, contentGen, async (series, ruleName, content) => { - const rule = createMockRulePrompt({series, ruleName, globs: [], content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).not.toContain('---') - expect(output).toBe(content) - }), {numRuns: 100}) - }) - - it('should preserve rule body content after frontmatter', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).toContain(content) - }), {numRuns: 100}) - }) - - it('should list each glob as a YAML array item under globs', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - for (const g of globs) expect(output).toMatch(new RegExp(`- "${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}"|- ${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)) - }), {numRuns: 100}) - }) - }) - - describe('write output format verification', () => { - it('should write global rule files with correct format to ~/.config/opencode/rules/', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, scope: 'global', content}) - const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, rules: [rule]}} as any - await plugin.writeGlobalOutputs(ctx) - const filePath = path.join(tempDir, '.config/opencode', 'rules', `rule-${series}-${ruleName}.md`) - expect(fs.existsSync(filePath)).toBe(true) - const written = fs.readFileSync(filePath, 'utf8') - expect(written).toContain('globs:') - expect(written).not.toMatch(/^paths:/m) - expect(written).toContain(content) - }), {numRuns: 30}) - }) - - it('should write project rule files to {project}/.opencode/rules/', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const mockProject: Project = { - name: 'proj', - dirFromWorkspacePath: createMockRelativePath('proj', tempDir), - rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', tempDir) as any, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}}, - childMemoryPrompts: [] - } - const rule = createMockRulePrompt({series, ruleName, globs, scope: 'project', content}) - const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, rules: [rule]}} as any - await plugin.writeProjectOutputs(ctx) - const filePath = path.join(tempDir, 'proj', '.opencode', 'rules', `rule-${series}-${ruleName}.md`) - expect(fs.existsSync(filePath)).toBe(true) - const written = fs.readFileSync(filePath, 'utf8') - expect(written).toContain('globs:') - expect(written).toContain(content) - }), {numRuns: 30}) - }) - }) -}) diff --git a/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.test.ts b/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.test.ts deleted file mode 100644 index 068f2dd0..00000000 --- a/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.test.ts +++ /dev/null @@ -1,777 +0,0 @@ -import type {CollectedInputContext, FastCommandPrompt, OutputPluginContext, Project, RelativePath, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {OpencodeCLIOutputPlugin} from './OpencodeCLIOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -class TestableOpencodeCLIOutputPlugin extends OpencodeCLIOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } - - public testBuildRuleFileName(rule: RulePrompt): string { - return (this as any).buildRuleFileName(rule) - } - - public testBuildRuleContent(rule: RulePrompt): string { - return (this as any).buildRuleContent(rule) - } -} - -function createMockRulePrompt(options: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { - const content = options.content ?? '# Rule body' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', ''), - markdownContents: [], - yamlFrontMatter: {description: 'ignored', globs: options.globs}, - series: options.series, - ruleName: options.ruleName, - globs: options.globs, - scope: options.scope ?? 'global' - } as RulePrompt -} - -describe('opencodeCLIOutputPlugin', () => { - let tempDir: string, - plugin: TestableOpencodeCLIOutputPlugin, - mockContext: OutputPluginContext - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-test-')) - plugin = new TestableOpencodeCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - mockContext = { - collectedInputContext: { - workspace: { - projects: [], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: { - type: PromptKind.GlobalMemory, - content: 'Global Memory Content', - filePathKind: FilePathKind.Absolute, - dir: createMockRelativePath('.', tempDir), - markdownContents: [] - }, - fastCommands: [], - subAgents: [], - skills: [] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - }) - - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch { - } // ignore cleanup errors - } - }) - - describe('constructor', () => { - it('should have correct plugin name', () => expect(plugin.name).toBe('OpencodeCLIOutputPlugin')) - - it('should have correct dependencies', () => expect(plugin.dependsOn).toContain('AgentsOutputPlugin')) - }) - - describe('registerGlobalOutputDirs', () => { - it('should register commands, agents, and skills subdirectories in .config/opencode', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - - const dirPaths = dirs.map(d => d.path) - expect(dirPaths).toContain('commands') - expect(dirPaths).toContain('agents') - expect(dirPaths).toContain('skills') - - const expectedBasePath = path.join(tempDir, '.config/opencode') - dirs.forEach(d => expect(d.basePath).toBe(expectedBasePath)) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should register project cleanup directories', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - rootMemoryPrompt: { - type: PromptKind.ProjectRootMemory, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir) as any, - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} - }, - childMemoryPrompts: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const dirs = await plugin.registerProjectOutputDirs(ctxWithProject) - const dirPaths = dirs.map(d => d.path) - - expect(dirPaths.some(p => p.includes(path.join('.config/opencode', 'commands')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.config/opencode', 'agents')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.config/opencode', 'skills')))).toBe(true) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register AGENTS.md in global config dir', async () => { - const files = await plugin.registerGlobalOutputFiles(mockContext) - const outputFile = files.find(f => f.path === 'AGENTS.md') - - expect(outputFile).toBeDefined() - expect(outputFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) - }) - - it('should register fast commands in commands subdirectory', async () => { - const mockCmd: FastCommandPrompt = { - type: PromptKind.FastCommand, - commandName: 'test-cmd', - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-cmd', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, description: 'desc'} - } - - const ctxWithCmd = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - fastCommands: [mockCmd] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithCmd) - const cmdFile = files.find(f => f.path.includes('test-cmd.md')) - - expect(cmdFile).toBeDefined() - expect(cmdFile?.path).toContain('commands') - expect(cmdFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) - }) - - it('should register agents in agents subdirectory', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('review-agent.md', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'review-agent', description: 'Code review agent'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('review-agent.md')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('agents') - expect(agentFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) - }) - - it('should strip .mdx suffix from agent path and use .md', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'agent content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('code-review.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'Code review agent'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('agents')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('code-review.cn.md') - expect(agentFile?.path).not.toContain('.mdx') - }) - - it('should register skills in skills subdirectory', 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'} - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) - const skillFile = files.find(f => f.path.includes('SKILL.md')) - - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain('skills') - expect(skillFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should return empty array (no project-level AGENTS.md)', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - childMemoryPrompts: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const files = await plugin.registerProjectOutputFiles(ctxWithProject) - expect(files).toEqual([]) - }) - }) - - describe('skill name normalization', () => { - it('should normalize skill names to opencode format', async () => { - const testCases = [ - {input: 'My Skill', expected: 'my-skill'}, - {input: 'Skill__Name', expected: 'skill-name'}, - {input: '-skill-', expected: 'skill'}, - {input: 'UPPER_CASE', expected: 'upper-case'}, - {input: 'tool.name', expected: 'tool-name'}, - {input: 'a'.repeat(70), expected: 'a'.repeat(64)} // truncated to 64 chars - ] - - for (const {input, expected} of testCases) { - const mockSkill: SkillPrompt = { - type: PromptKind.Skill, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath(input, tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: input, description: 'desc'} - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) - const skillFile = files.find(f => f.path.includes('SKILL.md')) - - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain(`skills/${expected}/`) - } - }) - }) - - describe('mcp config output', () => { - it('should register opencode.json when skill has 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: { - 'test-server': {command: 'test-cmd'} - } - } - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) - const configFile = files.find(f => f.path === 'opencode.json') - - expect(configFile).toBeDefined() - expect(configFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) - }) - - it('should write correct local 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', - args: ['index.js'], - env: {KEY: 'value'} - } - } - } - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - await plugin.writeGlobalOutputs(ctxWithSkill) - - const configPath = path.join(tempDir, '.config/opencode/opencode.json') - expect(fs.existsSync(configPath)).toBe(true) - - const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) - expect(content.mcp).toBeDefined() - expect(content.mcp['local-server']).toBeDefined() - expect(content.mcp['local-server'].type).toBe('local') - expect(content.mcp['local-server'].command).toEqual(['node', 'index.js']) - expect(content.mcp['local-server'].environment).toEqual({KEY: 'value'}) - expect(content.mcp['local-server'].enabled).toBe(true) - }) - - it('should write correct remote 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: { - 'remote-server': { - url: 'https://example.com/mcp' - } as any - } - } - } - - 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')) - - expect(content.mcp['remote-server']).toBeDefined() - expect(content.mcp['remote-server'].type).toBe('remote') - 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', () => { - it('should write sub agent file with .md extension when source has .mdx', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: '# Code Review Agent', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('reviewer.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'reviewer', description: 'Code review agent'} - } - - const writeCtx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const results = await plugin.writeGlobalOutputs(writeCtx) - const agentResult = results.files.find(f => f.path.path === 'reviewer.cn.md') - - expect(agentResult).toBeDefined() - expect(agentResult?.success).toBe(true) - - const writtenPath = path.join(tempDir, '.config/opencode', 'agents', 'reviewer.cn.md') - expect(fs.existsSync(writtenPath)).toBe(true) - expect(fs.existsSync(path.join(tempDir, '.config/opencode', 'agents', 'reviewer.cn.mdx'))).toBe(false) - expect(fs.existsSync(path.join(tempDir, '.config/opencode', 'agents', 'reviewer.cn.mdx.md'))).toBe(false) - }) - }) - - describe('buildRuleFileName', () => { - it('should produce rule-{series}-{ruleName}.md', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'naming', globs: []}) - expect(plugin.testBuildRuleFileName(rule)).toBe('rule-01-naming.md') - }) - }) - - describe('buildRuleContent', () => { - it('should return plain content when globs is empty', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: [], content: '# No globs'}) - expect(plugin.testBuildRuleContent(rule)).toBe('# No globs') - }) - - it('should use globs field (not paths) in YAML frontmatter per opencode-rules format', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], content: '# TS rule'}) - const content = plugin.testBuildRuleContent(rule) - expect(content).toContain('globs:') - expect(content).not.toMatch(/^paths:/m) - }) - - it('should output globs as YAML array items', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts', '**/*.tsx'], content: '# Body'}) - const content = plugin.testBuildRuleContent(rule) - expect(content).toContain('- "**/*.ts"') - expect(content).toContain('- "**/*.tsx"') - }) - - it('should preserve rule body after frontmatter', () => { - const body = '# My Rule\n\nSome content.' - const rule = createMockRulePrompt({series: '01', ruleName: 'x', globs: ['*.ts'], content: body}) - const content = plugin.testBuildRuleContent(rule) - expect(content).toContain(body) - }) - - it('should wrap content in valid YAML frontmatter delimiters', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'x', globs: ['*.ts'], content: '# Body'}) - const content = plugin.testBuildRuleContent(rule) - const lines = content.split('\n') - expect(lines[0]).toBe('---') - expect(lines.indexOf('---', 1)).toBeGreaterThan(0) - }) - }) - - describe('rules registration', () => { - it('should register rules subdir in global output dirs when global rules exist', async () => { - const ctx = { - ...mockContext, - collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} - } - const dirs = await plugin.registerGlobalOutputDirs(ctx) - expect(dirs.map(d => d.path)).toContain('rules') - }) - - it('should not register rules subdir when no global rules', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - expect(dirs.map(d => d.path)).not.toContain('rules') - }) - - it('should register global rule files in ~/.config/opencode/rules/', async () => { - const ctx = { - ...mockContext, - collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} - } - const files = await plugin.registerGlobalOutputFiles(ctx) - const ruleFile = files.find(f => f.path === 'rule-01-ts.md') - expect(ruleFile).toBeDefined() - expect(ruleFile?.basePath).toBe(path.join(tempDir, '.config/opencode', 'rules')) - }) - - it('should not register project rules as global files', async () => { - const ctx = { - ...mockContext, - collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'project'})]} - } - const files = await plugin.registerGlobalOutputFiles(ctx) - expect(files.find(f => f.path.includes('rule-'))).toBeUndefined() - }) - }) - - describe('canWrite with rules', () => { - it('should return true when rules exist even without other content', async () => { - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - globalMemory: void 0, - rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: []})] - } - } - expect(await plugin.canWrite(ctx as any)).toBe(true) - }) - }) - - describe('writeGlobalOutputs with rules', () => { - it('should write global rule file to ~/.config/opencode/rules/', async () => { - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global', content: '# TS rule'})] - } - } - const results = await plugin.writeGlobalOutputs(ctx as any) - const ruleResult = results.files.find(f => f.path.path === 'rule-01-ts.md') - expect(ruleResult?.success).toBe(true) - - const filePath = path.join(tempDir, '.config/opencode', 'rules', 'rule-01-ts.md') - expect(fs.existsSync(filePath)).toBe(true) - const content = fs.readFileSync(filePath, 'utf8') - expect(content).toContain('globs:') - expect(content).toContain('# TS rule') - }) - - it('should write rule without frontmatter when globs is empty', async () => { - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - rules: [createMockRulePrompt({series: '01', ruleName: 'general', globs: [], scope: 'global', content: '# Always apply'})] - } - } - await plugin.writeGlobalOutputs(ctx as any) - const filePath = path.join(tempDir, '.config/opencode', 'rules', 'rule-01-general.md') - const content = fs.readFileSync(filePath, 'utf8') - expect(content).toBe('# Always apply') - expect(content).not.toContain('---') - }) - }) - - describe('writeProjectOutputs with rules', () => { - it('should write project rule file to {project}/.opencode/rules/', async () => { - const mockProject: Project = { - name: 'proj', - dirFromWorkspacePath: createMockRelativePath('proj', tempDir), - rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', tempDir) as any, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}}, - childMemoryPrompts: [] - } - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, - rules: [createMockRulePrompt({series: '02', ruleName: 'api', globs: ['src/api/**'], scope: 'project', content: '# API rules'})] - } - } - const results = await plugin.writeProjectOutputs(ctx as any) - expect(results.files.some(f => f.path.path === 'rule-02-api.md' && f.success)).toBe(true) - - const filePath = path.join(tempDir, 'proj', '.opencode', 'rules', 'rule-02-api.md') - expect(fs.existsSync(filePath)).toBe(true) - const content = fs.readFileSync(filePath, 'utf8') - expect(content).toContain('globs:') - expect(content).toContain('# API rules') - }) - }) -}) diff --git a/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.ts b/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.ts deleted file mode 100644 index ca9fac85..00000000 --- a/packages/plugin-opencode-cli/src/OpencodeCLIOutputPlugin.ts +++ /dev/null @@ -1,467 +0,0 @@ -import type {FastCommandPrompt, McpServerConfig, OutputPluginContext, OutputWriteContext, RulePrompt, SkillPrompt, SubAgentPrompt, WriteResult, WriteResults} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' -import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' - -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' -const PROJECT_RULES_DIR = '.opencode' -const RULES_SUBDIR = 'rules' -const RULE_FILE_PREFIX = 'rule-' - -/** - * Opencode CLI output plugin. - * Outputs global memory, commands, agents, and skills to ~/.config/opencode/ - */ -export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { - constructor() { - super('OpencodeCLIOutputPlugin', { - globalConfigDir: GLOBAL_CONFIG_DIR, - outputFileName: GLOBAL_MEMORY_FILE, - commandsSubDir: 'commands', - agentsSubDir: 'agents', - skillsSubDir: 'skills', - supportsFastCommands: true, - supportsSubAgents: true, - supportsSkills: true, - dependsOn: [PLUGIN_NAMES.AgentsOutput] - }) - - this.registerCleanEffect('mcp-config-cleanup', async ctx => { - const globalDir = this.getGlobalConfigDir() - const configPath = path.join(globalDir, OPENCODE_CONFIG_FILE) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'mcpConfigCleanup', path: configPath}) - return {success: true, description: 'Would reset opencode.json mcp to empty'} - } - - try { - if (fs.existsSync(configPath)) { - 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}) - return {success: true, description: 'Reset opencode.json mcp to empty'} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'clean', type: 'mcpConfigCleanup', path: configPath, error: errMsg}) - return {success: false, error: error as Error, description: 'Failed to reset opencode.json mcp'} - } - }) - } - - override async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const results = await super.registerGlobalOutputFiles(ctx) - const globalDir = this.getGlobalConfigDir() - - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const filteredSkills = ctx.collectedInputContext.skills != null - ? filterSkillsByProjectConfig(ctx.collectedInputContext.skills, projectConfig) - : [] - const hasAnyMcpConfig = filteredSkills.some(s => s.mcpConfig != null) - if (hasAnyMcpConfig) { - const configPath = path.join(globalDir, OPENCODE_CONFIG_FILE) - results.push({ - pathKind: FilePathKind.Relative, - path: OPENCODE_CONFIG_FILE, - basePath: globalDir, - getDirectoryName: () => GLOBAL_CONFIG_DIR, - getAbsolutePath: () => configPath - }) - } - - const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') - if (globalRules != null && globalRules.length > 0) { - const rulesDir = path.join(globalDir, RULES_SUBDIR) - for (const rule of globalRules) results.push(this.createRelativePath(this.buildRuleFileName(rule), rulesDir, () => RULES_SUBDIR)) - } - - return results.map(result => { // Normalize skill directory names in paths - const normalizedPath = result.path.replaceAll('\\', '/') - const skillsPatternWithSlash = `/${this.skillsSubDir}/` - const skillsPatternStart = `${this.skillsSubDir}/` - - if (!(normalizedPath.includes(skillsPatternWithSlash) || normalizedPath.startsWith(skillsPatternStart))) return result - - const pathParts = normalizedPath.split('/') - const skillsIndex = pathParts.indexOf(this.skillsSubDir) - if (skillsIndex < 0 || skillsIndex + 1 >= pathParts.length) return result - - const skillName = pathParts[skillsIndex + 1] - if (skillName == null) return result - - const normalizedSkillName = this.validateAndNormalizeSkillName(skillName) - const newPathParts = [...pathParts] - newPathParts[skillsIndex + 1] = normalizedSkillName - const newPath = newPathParts.join('/') - return { - ...result, - path: newPath, - getDirectoryName: () => normalizedSkillName, - getAbsolutePath: () => path.join(globalDir, newPath.replaceAll('/', path.sep)) - } - }) - } - - override async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const baseResults = await super.writeGlobalOutputs(ctx) - const files = [...baseResults.files] - - const {skills} = ctx.collectedInputContext - if (skills != null) { - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - const mcpResult = await this.writeGlobalMcpConfig(ctx, filteredSkills) - if (mcpResult != null) files.push(mcpResult) - } - - const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') - if (globalRules == null || globalRules.length === 0) return {files, dirs: baseResults.dirs} - - const rulesDir = path.join(this.getGlobalConfigDir(), RULES_SUBDIR) - for (const rule of globalRules) files.push(await this.writeFile(ctx, path.join(rulesDir, this.buildRuleFileName(rule)), this.buildRuleContent(rule), 'rule')) - return {files, dirs: baseResults.dirs} - } - - private async writeGlobalMcpConfig( - ctx: OutputWriteContext, - skills: readonly SkillPrompt[] - ): Promise { - const mergedMcpServers: Record = {} - - for (const skill of skills) { - if (skill.mcpConfig == null) continue - const {mcpServers} = skill.mcpConfig - for (const [mcpName, mcpConfig] of Object.entries(mcpServers)) mergedMcpServers[mcpName] = this.transformMcpConfigForOpencode(mcpConfig) - } - - if (Object.keys(mergedMcpServers).length === 0) return null - - const globalDir = this.getGlobalConfigDir() - const configPath = path.join(globalDir, OPENCODE_CONFIG_FILE) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: OPENCODE_CONFIG_FILE, - basePath: globalDir, - getDirectoryName: () => GLOBAL_CONFIG_DIR, - getAbsolutePath: () => configPath - } - - let existingConfig: Record = {} - try { - if (fs.existsSync(configPath)) { - const content = fs.readFileSync(configPath, 'utf8') - existingConfig = JSON.parse(content) as Record - } - } - catch { - existingConfig = {} - } - - 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) { - this.log.trace({action: 'dryRun', type: 'globalMcpConfig', path: configPath, serverCount: Object.keys(mergedMcpServers).length}) - return {path: relativePath, success: true, skipped: false} - } - - try { - this.ensureDirectory(globalDir) - fs.writeFileSync(configPath, content) - this.log.trace({action: 'write', type: 'globalMcpConfig', path: configPath, serverCount: Object.keys(mergedMcpServers).length}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalMcpConfig', path: configPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private transformMcpConfigForOpencode(config: McpServerConfig): Record { - const result: Record = {} - - if (config.command != null) { - result['type'] = 'local' - const commandArray = [config.command] - if (config.args != null) commandArray.push(...config.args) - result['command'] = commandArray - if (config.env != null) result['environment'] = config.env - } else { - result['type'] = 'remote' - const configRecord = config as unknown as Record - if (configRecord['url'] != null) result['url'] = configRecord['url'] - else if (configRecord['serverUrl'] != null) result['url'] = configRecord['serverUrl'] - } - - result['enabled'] = config.disabled !== true - - return result - } - - protected override async writeSubAgent( - ctx: OutputWriteContext, - basePath: string, - agent: SubAgentPrompt - ): Promise { - const fileName = agent.dir.path.replace(/\.mdx$/, '.md') - const targetDir = path.join(basePath, this.agentsSubDir) - const fullPath = path.join(targetDir, fileName) - - const opencodeFrontMatter = this.buildOpencodeAgentFrontMatter(agent) - const content = this.buildMarkdownContent(agent.content, opencodeFrontMatter) - - return [await this.writeFile(ctx, fullPath, content, 'subAgent')] - } - - private buildOpencodeAgentFrontMatter(agent: SubAgentPrompt): Record { - const frontMatter: Record = {} - const source = agent.yamlFrontMatter as Record | undefined - - if (source?.['description'] != null) frontMatter['description'] = source['description'] - - frontMatter['mode'] = source?.['mode'] ?? 'subagent' - - if (source?.['model'] != null) frontMatter['model'] = source['model'] - if (source?.['temperature'] != null) frontMatter['temperature'] = source['temperature'] - if (source?.['maxSteps'] != null) frontMatter['maxSteps'] = source['maxSteps'] - if (source?.['hidden'] != null) frontMatter['hidden'] = source['hidden'] - - if (source?.['allowTools'] != null && Array.isArray(source['allowTools'])) { - const tools: Record = {} - for (const tool of source['allowTools']) tools[String(tool)] = true - frontMatter['tools'] = tools - } - - if (source?.['permission'] != null && typeof source['permission'] === 'object') frontMatter['permission'] = source['permission'] - - for (const [key, value] of Object.entries(source ?? {})) { - if (!['description', 'mode', 'model', 'temperature', 'maxSteps', 'hidden', 'allowTools', 'permission', 'namingCase', 'name', 'color'].includes(key)) { - frontMatter[key] = value - } - } - - return frontMatter - } - - protected override async writeFastCommand( - ctx: OutputWriteContext, - basePath: string, - cmd: FastCommandPrompt - ): Promise { - const transformOptions = this.getTransformOptionsFromContext(ctx) - const fileName = this.transformFastCommandName(cmd, transformOptions) - const targetDir = path.join(basePath, this.commandsSubDir) - const fullPath = path.join(targetDir, fileName) - - const opencodeFrontMatter = this.buildOpencodeCommandFrontMatter(cmd) - const content = this.buildMarkdownContent(cmd.content, opencodeFrontMatter) - - return [await this.writeFile(ctx, fullPath, content, 'fastCommand')] - } - - private buildOpencodeCommandFrontMatter(cmd: FastCommandPrompt): Record { - const frontMatter: Record = {} - const source = cmd.yamlFrontMatter as Record | undefined - - if (source?.['description'] != null) frontMatter['description'] = source['description'] - if (source?.['agent'] != null) frontMatter['agent'] = source['agent'] - if (source?.['model'] != null) frontMatter['model'] = source['model'] - - if (source?.['allowTools'] != null && Array.isArray(source['allowTools'])) { - const tools: Record = {} - for (const tool of source['allowTools']) tools[String(tool)] = true - frontMatter['tools'] = tools - } - - for (const [key, value] of Object.entries(source ?? {})) { - if (!['description', 'agent', 'model', 'allowTools', 'namingCase', 'argumentHint'].includes(key)) frontMatter[key] = value - } - - return frontMatter - } - - protected override async writeSkill( - ctx: OutputWriteContext, - basePath: string, - skill: SkillPrompt - ): Promise { - const results: WriteResult[] = [] - const skillName = this.validateAndNormalizeSkillName((skill.yamlFrontMatter?.name as string | undefined) ?? skill.dir.getDirectoryName()) - const targetDir = path.join(basePath, this.skillsSubDir, skillName) - const fullPath = path.join(targetDir, 'SKILL.md') - - const opencodeFrontMatter = this.buildOpencodeSkillFrontMatter(skill, skillName) - const content = this.buildMarkdownContent(skill.content as string, opencodeFrontMatter) - - const mainFileResult = await this.writeFile(ctx, fullPath, content, 'skill') - results.push(mainFileResult) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - const refResults = await this.writeSkillReferenceDocument(ctx, targetDir, skillName, refDoc, basePath) - results.push(...refResults) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) { - const refResults = await this.writeSkillResource(ctx, targetDir, skillName, resource, basePath) - results.push(...refResults) - } - } - - return results - } - - private buildOpencodeSkillFrontMatter(skill: SkillPrompt, skillName: string): Record { - const frontMatter: Record = {} - const source = skill.yamlFrontMatter as Record | undefined - - frontMatter['name'] = skillName - if (source?.['description'] != null) frontMatter['description'] = source['description'] - - frontMatter['license'] = source?.['license'] ?? 'MIT' - frontMatter['compatibility'] = source?.['compatibility'] ?? 'opencode' - - const metadata: Record = {} - const metadataFields = ['author', 'version', 'keywords', 'category', 'repository', 'displayName'] - - for (const field of metadataFields) { - if (source?.[field] != null) metadata[field] = source[field] - } - - const reservedFields = new Set(['name', 'description', 'license', 'compatibility', 'namingCase', 'allowTools', 'keywords', 'displayName', 'author', 'version']) - for (const [key, value] of Object.entries(source ?? {})) { - if (!reservedFields.has(key)) metadata[key] = value - } - - if (Object.keys(metadata).length > 0) frontMatter['metadata'] = metadata - - return frontMatter - } - - private validateAndNormalizeSkillName(name: string): string { - let normalized = name.toLowerCase() - normalized = normalized.replaceAll(/[^a-z0-9-]+/g, '-') - normalized = normalized.replaceAll(/-+/g, '-') - normalized = normalized.replaceAll(/^-|-$/g, '') - - if (normalized.length === 0) normalized = 'skill' - else if (normalized.length > 64) { - normalized = normalized.slice(0, 64) - normalized = normalized.replace(/-$/, '') - } - - return normalized - } - - private buildRuleFileName(rule: RulePrompt): string { - return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` - } - - private buildRuleContent(rule: RulePrompt): string { - if (rule.globs.length === 0) return rule.content - return this.buildMarkdownContent(rule.content, {globs: [...rule.globs]}) - } - - override async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { - const results = await super.registerGlobalOutputDirs(ctx) - const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === 'global') - if (globalRules != null && globalRules.length > 0) results.push(this.createRelativePath(RULES_SUBDIR, this.getGlobalConfigDir(), () => RULES_SUBDIR)) - return results - } - - override async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { - const results = await super.registerProjectOutputDirs(ctx) - const {rules} = ctx.collectedInputContext - if (rules == null || rules.length === 0) return results - for (const project of ctx.collectedInputContext.workspace.projects) { - if (project.dirFromWorkspacePath == null) continue - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => this.normalizeRuleScope(r) === 'project'), - project.projectConfig - ), - project.projectConfig - ) - if (projectRules.length === 0) continue - const dirPath = path.join(project.dirFromWorkspacePath.path, PROJECT_RULES_DIR, RULES_SUBDIR) - results.push(this.createRelativePath(dirPath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) - } - return results - } - - override async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results = await super.registerProjectOutputFiles(ctx) - const {rules} = ctx.collectedInputContext - if (rules == null || rules.length === 0) return results - for (const project of ctx.collectedInputContext.workspace.projects) { - if (project.dirFromWorkspacePath == null) continue - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => this.normalizeRuleScope(r) === 'project'), - project.projectConfig - ), - project.projectConfig - ) - for (const rule of projectRules) { - const filePath = path.join(project.dirFromWorkspacePath.path, PROJECT_RULES_DIR, RULES_SUBDIR, this.buildRuleFileName(rule)) - results.push(this.createRelativePath(filePath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) - } - } - return results - } - - override async canWrite(ctx: OutputWriteContext): Promise { - if ((ctx.collectedInputContext.rules?.length ?? 0) > 0) return true - return super.canWrite(ctx) - } - - override async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const results = await super.writeProjectOutputs(ctx) - const {rules} = ctx.collectedInputContext - if (rules == null || rules.length === 0) return results - const ruleResults = [] - for (const project of ctx.collectedInputContext.workspace.projects) { - if (project.dirFromWorkspacePath == null) continue - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => this.normalizeRuleScope(r) === 'project'), - project.projectConfig - ), - project.projectConfig - ) - if (projectRules.length === 0) continue - const rulesDir = path.join(project.dirFromWorkspacePath.basePath, project.dirFromWorkspacePath.path, PROJECT_RULES_DIR, RULES_SUBDIR) - for (const rule of projectRules) ruleResults.push(await this.writeFile(ctx, path.join(rulesDir, this.buildRuleFileName(rule)), this.buildRuleContent(rule), 'rule')) - } - return {files: [...results.files, ...ruleResults], dirs: results.dirs} - } -} diff --git a/packages/plugin-opencode-cli/src/index.ts b/packages/plugin-opencode-cli/src/index.ts deleted file mode 100644 index 7ce39288..00000000 --- a/packages/plugin-opencode-cli/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - OpencodeCLIOutputPlugin -} from './OpencodeCLIOutputPlugin' diff --git a/packages/plugin-opencode-cli/tsconfig.eslint.json b/packages/plugin-opencode-cli/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-opencode-cli/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-opencode-cli/tsconfig.json b/packages/plugin-opencode-cli/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-opencode-cli/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-opencode-cli/tsconfig.lib.json b/packages/plugin-opencode-cli/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-opencode-cli/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-opencode-cli/tsconfig.test.json b/packages/plugin-opencode-cli/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-opencode-cli/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-opencode-cli/tsdown.config.ts b/packages/plugin-opencode-cli/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-opencode-cli/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-opencode-cli/vite.config.ts b/packages/plugin-opencode-cli/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-opencode-cli/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-opencode-cli/vitest.config.ts b/packages/plugin-opencode-cli/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-opencode-cli/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-output-shared/eslint.config.ts b/packages/plugin-output-shared/eslint.config.ts deleted file mode 100644 index 2b7b269c..00000000 --- a/packages/plugin-output-shared/eslint.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' - -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: { - allowDefaultProject: true - } - }, - ignores: [ - '.turbo/**', - '*.md', - '**/*.md' - ] -}) - -export default config as unknown diff --git a/packages/plugin-output-shared/package.json b/packages/plugin-output-shared/package.json deleted file mode 100644 index fe0b251e..00000000 --- a/packages/plugin-output-shared/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@truenine/plugin-output-shared", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Shared abstract base classes and utilities for memory-sync output plugins", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - }, - "./utils": { - "types": "./dist/utils/index.d.mts", - "import": "./dist/utils/index.mjs" - }, - "./registry": { - "types": "./dist/registry/index.d.mts", - "import": "./dist/registry/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": { - "@truenine/config": "workspace:*", - "picomatch": "catalog:" - }, - "devDependencies": { - "@truenine/desk-paths": "workspace:*", - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-input-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "fast-check": "catalog:", - "fast-glob": "catalog:" - } -} diff --git a/packages/plugin-output-shared/src/AbstractOutputPlugin.test.ts b/packages/plugin-output-shared/src/AbstractOutputPlugin.test.ts deleted file mode 100644 index 04dd185e..00000000 --- a/packages/plugin-output-shared/src/AbstractOutputPlugin.test.ts +++ /dev/null @@ -1,717 +0,0 @@ -import type { - AIAgentIgnoreConfigFile, - FastCommandPrompt, - OutputWriteContext, - PluginOptions, - Project, - RelativePath, - WriteResult -} from '@truenine/plugin-shared' -import type {FastCommandNameTransformOptions} from './AbstractOutputPlugin' - -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' -import {AbstractOutputPlugin} from './AbstractOutputPlugin' - -class TestOutputPlugin extends AbstractOutputPlugin { // Create a concrete test implementation - constructor(pluginName: string = 'TestOutputPlugin') { - super(pluginName, {outputFileName: 'TEST.md'}) - } - - public testExtractGlobalMemoryContent(ctx: OutputWriteContext) { // Expose protected methods for testing - return this.extractGlobalMemoryContent(ctx) - } - - public testCombineGlobalWithContent( - globalContent: string | undefined, - projectContent: string, - options?: any - ) { - return this.combineGlobalWithContent(globalContent, projectContent, options) - } - - public testTransformFastCommandName( - cmd: FastCommandPrompt, - options?: FastCommandNameTransformOptions - ) { - return this.transformFastCommandName(cmd, options) - } - - public testGetFastCommandSeriesOptions(ctx: OutputWriteContext) { - return this.getFastCommandSeriesOptions(ctx) - } - - public testGetTransformOptionsFromContext( - ctx: OutputWriteContext, - additionalOptions?: FastCommandNameTransformOptions - ) { - 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 { - 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 -} - -function createMockContext(globalContent?: string, pluginOptions?: PluginOptions): OutputWriteContext { - const hasGlobalContent = globalContent != null && globalContent.trim().length > 0 - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', '/test'), - projects: [] - }, - ideConfigFiles: [], - globalMemory: hasGlobalContent - ? { - type: PromptKind.GlobalMemory, - content: globalContent, - dir: createMockRelativePath('.', '/test'), - markdownContents: [], - length: globalContent.length, - filePathKind: FilePathKind.Relative, - parentDirectoryPath: { - type: 'UserHome', - directory: createMockRelativePath('.memory', '/home/user') - } - } as any - : (null as any) - } as any, - dryRun: false, - pluginOptions - } as unknown as OutputWriteContext -} - -describe('abstractOutputPlugin', () => { - describe('extractGlobalMemoryContent', () => { - it('should extract global memory content when present', () => { - const plugin = new TestOutputPlugin() - const ctx = createMockContext('Global content here') - - const result = plugin.testExtractGlobalMemoryContent(ctx) - - expect(result).toBe('Global content here') - }) - - it('should return undefined when global memory is not present', () => { - const plugin = new TestOutputPlugin() - const ctx = createMockContext() - - const result = plugin.testExtractGlobalMemoryContent(ctx) - - expect(result).toBeUndefined() - }) - - it('should return undefined when global memory content is undefined', () => { - const plugin = new TestOutputPlugin() - const ctx = createMockContext(); - (ctx.collectedInputContext as any).globalMemory = { - type: PromptKind.GlobalMemory, - dir: createMockRelativePath('.', '/test'), - markdownContents: [], - length: 0, - filePathKind: FilePathKind.Relative, - parentDirectoryPath: { - type: 'UserHome', - directory: createMockRelativePath('.memory', '/home/user') - } - } as any - - const result = plugin.testExtractGlobalMemoryContent(ctx) - - expect(result).toBeUndefined() - }) - }) - - describe('combineGlobalWithContent', () => { - it('should combine global and project content with default options', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent('Global', 'Project') - - expect(result).toBe('Global\n\nProject') - }) - - it('should skip empty global content by default', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent('', 'Project') - - expect(result).toBe('Project') - }) - - it('should skip whitespace-only global content by default', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent(' \n\n ', 'Project') - - expect(result).toBe('Project') - }) - - it('should skip undefined global content by default', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent(null as any, 'Project') - - expect(result).toBe('Project') - }) - - it('should use custom separator when provided', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent('Global', 'Project', {separator: '\n---\n'}) - - expect(result).toBe('Global\n---\nProject') - }) - - it('should place global content after when position is "after"', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent('Global', 'Project', {position: 'after'}) - - expect(result).toBe('Project\n\nGlobal') - }) - - it('should place global content before when position is "before"', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent('Global', 'Project', {position: 'before'}) - - expect(result).toBe('Global\n\nProject') - }) - - it('should not skip empty content when skipIfEmpty is false', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent('', 'Project', {skipIfEmpty: false}) - - expect(result).toBe('\n\nProject') - }) - - it('should not skip whitespace content when skipIfEmpty is false', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent(' ', 'Project', {skipIfEmpty: false}) - - expect(result).toBe(' \n\nProject') - }) - - it('should treat undefined as empty string when skipIfEmpty is false', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent(null as any, 'Project', {skipIfEmpty: false}) - - expect(result).toBe('\n\nProject') - }) - - it('should combine multiple options correctly', () => { - const plugin = new TestOutputPlugin() - const result = plugin.testCombineGlobalWithContent('Global', 'Project', {separator: '\n===\n', position: 'after', skipIfEmpty: true}) - - expect(result).toBe('Project\n===\nGlobal') - }) - - it('should handle multi-line content correctly', () => { - const plugin = new TestOutputPlugin() - const globalContent = '# Global Rules\n\nThese are global.' - const projectContent = '# Project Rules\n\nThese are project-specific.' - const result = plugin.testCombineGlobalWithContent(globalContent, projectContent) - - expect(result).toBe( - '# Global Rules\n\nThese are global.\n\n# Project Rules\n\nThese are project-specific.' - ) - }) - }) - - describe('transformFastCommandName', () => { - const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) // Generator for alphanumeric strings without underscore (for series prefix) - .filter(s => /^[a-z0-9]+$/i.test(s)) - - const alphanumericCommandName = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generator for alphanumeric strings (for command name) - .filter(s => /^\w+$/.test(s)) - - const separatorChar = fc.constantFrom('_', '-', '.', '~') // Generator for separator characters - - it('should include series prefix with default separator when includeSeriesPrefix is true or undefined', () => { - fc.assert( - fc.property( - alphanumericNoUnderscore, - alphanumericCommandName, - (series, commandName) => { - const plugin = new TestOutputPlugin() - const cmd = createMockFastCommandPrompt(series, commandName) - - const resultTrue = plugin.testTransformFastCommandName(cmd, {includeSeriesPrefix: true}) // Test with includeSeriesPrefix = true - expect(resultTrue).toBe(`${series}-${commandName}.md`) - - const resultDefault = plugin.testTransformFastCommandName(cmd) // Test with includeSeriesPrefix = undefined (default) - expect(resultDefault).toBe(`${series}-${commandName}.md`) - } - ), - {numRuns: 100} - ) - }) - - it('should exclude series prefix when includeSeriesPrefix is false', () => { - fc.assert( - fc.property( - alphanumericNoUnderscore, - alphanumericCommandName, - (series, commandName) => { - const plugin = new TestOutputPlugin() - const cmd = createMockFastCommandPrompt(series, commandName) - - const result = plugin.testTransformFastCommandName(cmd, {includeSeriesPrefix: false}) - expect(result).toBe(`${commandName}.md`) - } - ), - {numRuns: 100} - ) - }) - - it('should use configurable separator between series and command name', () => { - fc.assert( - fc.property( - alphanumericNoUnderscore, - alphanumericCommandName, - separatorChar, - (series, commandName, separator) => { - const plugin = new TestOutputPlugin() - const cmd = createMockFastCommandPrompt(series, commandName) - - const result = plugin.testTransformFastCommandName(cmd, {includeSeriesPrefix: true, seriesSeparator: separator}) - expect(result).toBe(`${series}${separator}${commandName}.md`) - } - ), - {numRuns: 100} - ) - }) - - it('should return just commandName.md when series is undefined', () => { - fc.assert( - fc.property( - alphanumericCommandName, - fc.boolean(), - separatorChar, - (commandName, includePrefix, separator) => { - const plugin = new TestOutputPlugin() - const cmd = createMockFastCommandPrompt(void 0, commandName) - - const result = plugin.testTransformFastCommandName(cmd, { // Regardless of includeSeriesPrefix setting, should return just commandName - includeSeriesPrefix: includePrefix, - seriesSeparator: separator - }) - expect(result).toBe(`${commandName}.md`) - } - ), - {numRuns: 100} - ) - }) - - it('should handle pe_compile correctly with default options', () => { // Unit tests for specific edge cases - const plugin = new TestOutputPlugin() - const cmd = createMockFastCommandPrompt('pe', 'compile') - - const result = plugin.testTransformFastCommandName(cmd) - expect(result).toBe('pe-compile.md') - }) - - it('should handle pe_compile with hyphen separator (Kiro style)', () => { - const plugin = new TestOutputPlugin() - const cmd = createMockFastCommandPrompt('pe', 'compile') - - const result = plugin.testTransformFastCommandName(cmd, {seriesSeparator: '-'}) - expect(result).toBe('pe-compile.md') - }) - - it('should handle command without series', () => { - const plugin = new TestOutputPlugin() - const cmd = createMockFastCommandPrompt(void 0, 'compile') - - const result = plugin.testTransformFastCommandName(cmd) - expect(result).toBe('compile.md') - }) - - it('should strip prefix when includeSeriesPrefix is false', () => { - const plugin = new TestOutputPlugin() - const cmd = createMockFastCommandPrompt('pe', 'compile') - - const result = plugin.testTransformFastCommandName(cmd, {includeSeriesPrefix: false}) - expect(result).toBe('compile.md') - }) - }) - - describe('getFastCommandSeriesOptions and getTransformOptionsFromContext', () => { - const pluginNameGen = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generator for plugin names - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const separatorGen = fc.constantFrom('_', '-', '.', '~') // Generator for separator characters - - it('should return plugin-specific override when it exists', () => { - fc.assert( - fc.property( - pluginNameGen, - fc.boolean(), - separatorGen, - fc.boolean(), - separatorGen, - (pluginName, globalInclude, _globalSep, pluginInclude, pluginSep) => { - const plugin = new TestOutputPlugin(pluginName) - const ctx = createMockContext(void 0, { - fastCommandSeriesOptions: { - includeSeriesPrefix: globalInclude, - pluginOverrides: { - [pluginName]: { - includeSeriesPrefix: pluginInclude, - seriesSeparator: pluginSep - } - } - } - }) - - const result = plugin.testGetFastCommandSeriesOptions(ctx) - - expect(result.includeSeriesPrefix).toBe(pluginInclude) // Plugin-specific override should take precedence - expect(result.seriesSeparator).toBe(pluginSep) - } - ), - {numRuns: 100} - ) - }) - - it('should fall back to global settings when no plugin override exists', () => { - fc.assert( - fc.property( - pluginNameGen, - fc.boolean(), - (pluginName, globalInclude) => { - const plugin = new TestOutputPlugin(pluginName) - const ctx = createMockContext(void 0, { - fastCommandSeriesOptions: { - includeSeriesPrefix: globalInclude - } - }) - - const result = plugin.testGetFastCommandSeriesOptions(ctx) - - expect(result.includeSeriesPrefix).toBe(globalInclude) // Should use global setting - expect(result.seriesSeparator).not.toBeDefined() // seriesSeparator should not be set - } - ), - {numRuns: 100} - ) - }) - - it('should return empty options when no configuration exists', () => { - fc.assert( - fc.property( - pluginNameGen, - pluginName => { - const plugin = new TestOutputPlugin(pluginName) - const ctx = createMockContext() - - const result = plugin.testGetFastCommandSeriesOptions(ctx) - - expect(result.includeSeriesPrefix).not.toBeDefined() - expect(result.seriesSeparator).not.toBeDefined() - } - ), - {numRuns: 100} - ) - }) - - it('should merge additionalOptions with config options in getTransformOptionsFromContext', () => { - fc.assert( - fc.property( - pluginNameGen, - fc.boolean(), - separatorGen, - separatorGen, - (pluginName, configInclude, configSep, additionalSep) => { - const plugin = new TestOutputPlugin(pluginName) - const ctx = createMockContext(void 0, { - fastCommandSeriesOptions: { - includeSeriesPrefix: configInclude, - pluginOverrides: { - [pluginName]: { - seriesSeparator: configSep - } - } - } - }) - - const result = plugin.testGetTransformOptionsFromContext(ctx, { // Config separator should override additional options - seriesSeparator: additionalSep - }) - - expect(result.includeSeriesPrefix).toBe(configInclude) - expect(result.seriesSeparator).toBe(configSep) // Config separator takes precedence over additional options - } - ), - {numRuns: 100} - ) - }) - - it('should use additionalOptions when config does not specify the option', () => { - fc.assert( - fc.property( - pluginNameGen, - fc.boolean(), - separatorGen, - (pluginName, additionalInclude, additionalSep) => { - const plugin = new TestOutputPlugin(pluginName) - const ctx = createMockContext() // No fastCommandSeriesOptions in config - - const result = plugin.testGetTransformOptionsFromContext(ctx, {includeSeriesPrefix: additionalInclude, seriesSeparator: additionalSep}) - - expect(result.includeSeriesPrefix).toBe(additionalInclude) // Should use additional options as fallback - expect(result.seriesSeparator).toBe(additionalSep) - } - ), - {numRuns: 100} - ) - }) - - it('should handle KiroCLIOutputPlugin style configuration', () => { // Unit tests for specific scenarios - const plugin = new TestOutputPlugin('KiroCLIOutputPlugin') - const ctx = createMockContext(void 0, { - fastCommandSeriesOptions: { - includeSeriesPrefix: false, - pluginOverrides: { - KiroCLIOutputPlugin: { - includeSeriesPrefix: true, - seriesSeparator: '-' - } - } - } - }) - - const result = plugin.testGetFastCommandSeriesOptions(ctx) - - expect(result.includeSeriesPrefix).toBe(true) // Plugin override should take precedence - expect(result.seriesSeparator).toBe('-') - }) - - it('should handle partial plugin override (only seriesSeparator)', () => { - const plugin = new TestOutputPlugin('TestPlugin') - const ctx = createMockContext(void 0, { - fastCommandSeriesOptions: { - includeSeriesPrefix: true, - pluginOverrides: { - TestPlugin: { - seriesSeparator: '-' - } - } - } - }) - - const result = plugin.testGetFastCommandSeriesOptions(ctx) - - expect(result.includeSeriesPrefix).toBe(true) // includeSeriesPrefix should fall back to global - 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/packages/plugin-output-shared/src/AbstractOutputPlugin.ts b/packages/plugin-output-shared/src/AbstractOutputPlugin.ts deleted file mode 100644 index 59f3572e..00000000 --- a/packages/plugin-output-shared/src/AbstractOutputPlugin.ts +++ /dev/null @@ -1,543 +0,0 @@ -import type {CleanEffectHandler, EffectRegistration, EffectResult, FastCommandPrompt, ILogger, OutputCleanContext, OutputPlugin, OutputPluginContext, OutputWriteContext, Project, RegistryOperationResult, RulePrompt, RuleScope, WriteEffectHandler, WriteResult, WriteResults} from '@truenine/plugin-shared' -import type {FastCommandSeriesPluginOverride, Path, ProjectConfig, RegistryData, RelativePath} from '@truenine/plugin-shared/types' - -import type {Buffer} from 'node:buffer' -import type {RegistryWriter} from './registry/RegistryWriter' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import process from 'node:process' -import { - createFileRelativePath as deskCreateFileRelativePath, - createRelativePath as deskCreateRelativePath, - createSymlink as deskCreateSymlink, - ensureDir as deskEnsureDir, - isSymlink as deskIsSymlink, - lstatSync as deskLstatSync, - removeSymlink as deskRemoveSymlink, - writeFileSync as deskWriteFileSync -} from '@truenine/desk-paths' -import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import { - AbstractPlugin, - FilePathKind, - PluginKind -} from '@truenine/plugin-shared' - -/** - * Options for transforming fast command names in output filenames. - * Used by transformFastCommandName method to control prefix handling. - */ -export interface FastCommandNameTransformOptions { - readonly includeSeriesPrefix?: boolean - readonly seriesSeparator?: string -} - -/** - * Options for configuring AbstractOutputPlugin subclasses. - */ -export interface AbstractOutputPluginOptions { - globalConfigDir?: string - - outputFileName?: string - - dependsOn?: readonly string[] - - indexignore?: string -} - -/** - * Options for combining global content with project content. - */ -export interface CombineOptions { - separator?: string - - skipIfEmpty?: boolean - - position?: 'before' | 'after' -} - -export abstract class AbstractOutputPlugin extends AbstractPlugin implements OutputPlugin { - protected readonly globalConfigDir: string - - protected readonly outputFileName: string - - protected readonly indexignore: string | undefined - - private readonly registryWriterCache: Map> = new Map() - - private readonly writeEffects: EffectRegistration[] = [] - - private readonly cleanEffects: EffectRegistration[] = [] - - protected constructor(name: string, options?: AbstractOutputPluginOptions) { - super(name, PluginKind.Output, options?.dependsOn) - this.globalConfigDir = options?.globalConfigDir ?? '' - this.outputFileName = options?.outputFileName ?? '' - this.indexignore = options?.indexignore - } - - protected resolvePromptSourceProjectConfig(ctx: OutputPluginContext | OutputWriteContext): ProjectConfig | undefined { - const {projects} = ctx.collectedInputContext.workspace - const promptSource = projects.find(p => p.isPromptSourceProject === true) - return promptSource?.projectConfig ?? projects[0]?.projectConfig - } - - protected registerWriteEffect(name: string, handler: WriteEffectHandler): void { - this.writeEffects.push({name, handler}) - } - - protected registerCleanEffect(name: string, handler: CleanEffectHandler): void { - this.cleanEffects.push({name, handler}) - } - - protected async executeWriteEffects(ctx: OutputWriteContext): Promise { - const results: EffectResult[] = [] - - for (const effect of this.writeEffects) { - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'effect', name: effect.name}) - results.push({success: true, description: `Would execute write effect: ${effect.name}`}) - continue - } - - try { - const result = await effect.handler(ctx) - if (result.success) this.log.trace({action: 'effect', name: effect.name, status: 'success'}) - else { - const errorMsg = result.error instanceof Error ? result.error.message : String(result.error) - this.log.error({action: 'effect', name: effect.name, status: 'failed', error: errorMsg}) - } - results.push(result) - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'effect', name: effect.name, status: 'failed', error: errorMsg}) - results.push({success: false, error: error as Error, description: `Write effect failed: ${effect.name}`}) - } - } - - return results - } - - protected async executeCleanEffects(ctx: OutputCleanContext): Promise { - const results: EffectResult[] = [] - - for (const effect of this.cleanEffects) { - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'effect', name: effect.name}) - results.push({success: true, description: `Would execute clean effect: ${effect.name}`}) - continue - } - - try { - const result = await effect.handler(ctx) - if (result.success) this.log.trace({action: 'effect', name: effect.name, status: 'success'}) - else { - const errorMsg = result.error instanceof Error ? result.error.message : String(result.error) - this.log.error({action: 'effect', name: effect.name, status: 'failed', error: errorMsg}) - } - results.push(result) - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'effect', name: effect.name, status: 'failed', error: errorMsg}) - results.push({success: false, error: error as Error, description: `Clean effect failed: ${effect.name}`}) - } - } - - return results - } - - protected isRelativePath(p: Path): p is RelativePath { - return p.pathKind === FilePathKind.Relative - } - - protected toRelativePath(p: Path): RelativePath { - if (this.isRelativePath(p)) return p - return { // Fallback for non-relative paths - pathKind: FilePathKind.Relative, - path: p.path, - basePath: '', - getDirectoryName: p.getDirectoryName, - getAbsolutePath: () => p.path - } - } - - protected resolveFullPath(targetPath: Path, outputFileName?: string): string { - let dirPath: string - if (targetPath.pathKind === FilePathKind.Absolute) dirPath = targetPath.path - else if (this.isRelativePath(targetPath)) dirPath = path.resolve(targetPath.basePath, targetPath.path) - else dirPath = path.resolve(process.cwd(), targetPath.path) - - const fileName = outputFileName ?? this.outputFileName // Append the output file name if provided or if default is set - if (fileName) return path.join(dirPath, fileName) - return dirPath - } - - protected createRelativePath( - pathStr: string, - basePath: string, - dirNameFn: () => string - ): RelativePath { - return deskCreateRelativePath(pathStr, basePath, dirNameFn) - } - - protected createFileRelativePath(dir: RelativePath, fileName: string): RelativePath { - return deskCreateFileRelativePath(dir, fileName) - } - - protected getGlobalConfigDir(): string { - return path.join(this.getHomeDir(), this.globalConfigDir) - } - - protected getHomeDir(): string { - return os.homedir() - } - - protected joinPath(...segments: string[]): string { - return path.join(...segments) - } - - protected resolvePath(...segments: string[]): string { - return path.resolve(...segments) - } - - protected dirname(p: string): string { - return path.dirname(p) - } - - protected basename(p: string, ext?: string): string { - return path.basename(p, ext) - } - - protected writeFileSync(filePath: string, content: string, encoding: BufferEncoding = 'utf8'): void { - deskWriteFileSync(filePath, content, encoding) - } - - protected writeFileSyncBuffer(filePath: string, buffer: Buffer): void { - deskWriteFileSync(filePath, buffer) - } - - protected ensureDirectory(dir: string): void { - deskEnsureDir(dir) - } - - protected existsSync(p: string): boolean { - return fs.existsSync(p) - } - - protected lstatSync(p: string): fs.Stats { - return deskLstatSync(p) - } - - protected isSymlink(p: string): boolean { - return deskIsSymlink(p) - } - - protected createSymlink(targetPath: string, symlinkPath: string, type: 'file' | 'dir' = 'dir'): void { - deskCreateSymlink(targetPath, symlinkPath, type) - } - - protected removeSymlink(symlinkPath: string): void { - deskRemoveSymlink(symlinkPath) - } - - protected async writeDirectorySymlink( - ctx: OutputWriteContext, - targetPath: string, - symlinkPath: string, - label: string - ): Promise { - const dir = path.dirname(symlinkPath) - const linkName = path.basename(symlinkPath) - const relativePath: RelativePath = deskCreateRelativePath(linkName, dir, () => path.basename(dir)) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'symlink', target: targetPath, link: symlinkPath, label}) - return {path: relativePath, success: true, skipped: false} - } - - try { - this.createSymlink(targetPath, symlinkPath, 'dir') - this.log.trace({action: 'write', type: 'symlink', target: targetPath, link: symlinkPath, label}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'symlink', target: targetPath, link: symlinkPath, label, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - protected readdirSync(dir: string, options: {withFileTypes: true}): fs.Dirent[] - protected readdirSync(dir: string): string[] - protected readdirSync(dir: string, options?: {withFileTypes?: boolean}): fs.Dirent[] | string[] { - if (options?.withFileTypes === true) return fs.readdirSync(dir, {withFileTypes: true}) - return fs.readdirSync(dir) - } - - protected getIgnoreOutputPath(): string | undefined { - if (this.indexignore == null) return void 0 - return this.indexignore - } - - protected registerProjectIgnoreOutputFiles(projects: readonly Project[]): RelativePath[] { - const outputPath = this.getIgnoreOutputPath() - if (outputPath == null) return [] - - const results: RelativePath[] = [] - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - if (project.isPromptSourceProject === true) continue - - const filePath = path.join(projectDir.path, outputPath) - results.push({ - pathKind: FilePathKind.Relative, - path: filePath, - basePath: projectDir.basePath, - getDirectoryName: () => 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 { - fs.mkdirSync(path.dirname(fullPath), {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, - content: string, - label: string - ): Promise { - const dir = path.dirname(fullPath) // Create a relative path for the result - const fileName = path.basename(fullPath) - const relativePath: RelativePath = deskCreateRelativePath(fileName, dir, () => path.basename(dir)) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'file', path: fullPath, label}) - return {path: relativePath, success: true, skipped: false} - } - - try { - deskWriteFileSync(fullPath, content) - this.log.trace({action: 'write', type: 'file', 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: 'file', path: fullPath, label, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - protected async writePromptFile( - ctx: OutputWriteContext, - targetPath: Path, - content: string, - label: string - ): Promise { - const fullPath = this.resolveFullPath(targetPath) - const relativePath = this.toRelativePath(targetPath) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'promptFile', path: fullPath, label}) - return {path: relativePath, success: true, skipped: false} - } - - try { - deskWriteFileSync(fullPath, content) - this.log.trace({action: 'write', type: 'promptFile', 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: 'promptFile', path: fullPath, label, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - protected buildMarkdownContent(content: string, frontMatter?: Record): string { - return buildMarkdownWithFrontMatter(frontMatter, content) - } - - protected buildMarkdownContentWithRaw( - content: string, - frontMatter?: Record, - rawFrontMatter?: string - ): string { - if (frontMatter != null && Object.keys(frontMatter).length > 0) return buildMarkdownWithFrontMatter(frontMatter, content) // If we have parsed front matter, use it - - if (rawFrontMatter != null && rawFrontMatter.length > 0) return `---\n${rawFrontMatter}\n---\n${content}` // If we have raw front matter but parsing failed, use raw - - return content // No front matter - } - - protected extractGlobalMemoryContent(ctx: OutputWriteContext): string | undefined { - return ctx.collectedInputContext.globalMemory?.content as string | undefined - } - - protected combineGlobalWithContent( - globalContent: string | undefined, - projectContent: string, - options?: CombineOptions - ): string { - const { - separator = '\n\n', - skipIfEmpty = true, - position = 'before' - } = options ?? {} - - if (skipIfEmpty && (globalContent == null || globalContent.trim().length === 0)) return projectContent // Skip if global content is undefined/null or empty/whitespace when skipIfEmpty is true - - const effectiveGlobalContent = globalContent ?? '' // If global content is null/undefined but skipIfEmpty is false, treat as empty string - - if (position === 'after') return `${projectContent}${separator}${effectiveGlobalContent}` // Combine based on position - - return `${effectiveGlobalContent}${separator}${projectContent}` // Default: 'before' - } - - protected transformFastCommandName( - cmd: FastCommandPrompt, - options?: FastCommandNameTransformOptions - ): string { - const {includeSeriesPrefix = true, seriesSeparator = '-'} = options ?? {} - - if (!includeSeriesPrefix || cmd.series == null) return `${cmd.commandName}.md` // If prefix should not be included or series is not present, return just commandName - - return `${cmd.series}${seriesSeparator}${cmd.commandName}.md` - } - - protected getFastCommandSeriesOptions(ctx: OutputWriteContext): FastCommandSeriesPluginOverride { - const globalOptions = ctx.pluginOptions?.fastCommandSeriesOptions - const pluginOverride = globalOptions?.pluginOverrides?.[this.name] - - const includeSeriesPrefix = pluginOverride?.includeSeriesPrefix ?? globalOptions?.includeSeriesPrefix // Only include properties that have defined values to satisfy exactOptionalPropertyTypes // Plugin-specific overrides take precedence over global settings - const seriesSeparator = pluginOverride?.seriesSeparator - - if (includeSeriesPrefix != null && seriesSeparator != null) return {includeSeriesPrefix, seriesSeparator} // Build result object conditionally to avoid assigning undefined to readonly properties - if (includeSeriesPrefix != null) return {includeSeriesPrefix} - if (seriesSeparator != null) return {seriesSeparator} - return {} - } - - protected getTransformOptionsFromContext( - ctx: OutputWriteContext, - additionalOptions?: FastCommandNameTransformOptions - ): FastCommandNameTransformOptions { - const seriesOptions = this.getFastCommandSeriesOptions(ctx) - - const includeSeriesPrefix = seriesOptions.includeSeriesPrefix ?? additionalOptions?.includeSeriesPrefix // Only include properties that have defined values to satisfy exactOptionalPropertyTypes // Merge: additionalOptions (plugin defaults) <- seriesOptions (config overrides) - const seriesSeparator = seriesOptions.seriesSeparator ?? additionalOptions?.seriesSeparator - - if (includeSeriesPrefix != null && seriesSeparator != null) return {includeSeriesPrefix, seriesSeparator} // Build result object conditionally to avoid assigning undefined to readonly properties - if (includeSeriesPrefix != null) return {includeSeriesPrefix} - if (seriesSeparator != null) return {seriesSeparator} - return {} - } - - protected shouldSkipDueToPlugin(ctx: OutputWriteContext, precedingPluginName: string): boolean { - const registeredPlugins = ctx.registeredPluginNames - if (registeredPlugins == null) return false - return registeredPlugins.includes(precedingPluginName) - } - - async onWriteComplete(ctx: OutputWriteContext, results: WriteResults): Promise { - const success = results.files.filter(r => r.success).length - const skipped = results.files.filter(r => r.skipped).length - const failed = results.files.filter(r => !r.success && !r.skipped).length - - this.log.trace({action: ctx.dryRun === true ? 'dryRun' : 'complete', type: 'writeSummary', success, skipped, failed}) - - await this.executeWriteEffects(ctx) // Execute registered write effects - } - - async onCleanComplete(ctx: OutputCleanContext): Promise { - await this.executeCleanEffects(ctx) // Execute registered clean effects - } - - protected getRegistryWriter< - TEntry, - TRegistry extends RegistryData, - T extends RegistryWriter - >( - WriterClass: new (logger: ILogger) => T - ): T { - const cacheKey = WriterClass.name - - const cached = this.registryWriterCache.get(cacheKey) // Check cache first - if (cached != null) return cached as T - - const writer = new WriterClass(this.log) // Create new instance and cache it - this.registryWriterCache.set(cacheKey, writer as RegistryWriter) - return writer - } - - protected async registerInRegistry< - TEntry, - TRegistry extends RegistryData - >( - writer: RegistryWriter, - entries: readonly TEntry[], - ctx: OutputWriteContext - ): Promise { - return writer.register(entries, ctx.dryRun) - } - - protected normalizeRuleScope(rule: RulePrompt): RuleScope { - return rule.scope ?? 'project' - } -} diff --git a/packages/plugin-output-shared/src/BaseCLIOutputPlugin.ts b/packages/plugin-output-shared/src/BaseCLIOutputPlugin.ts deleted file mode 100644 index 3c58599a..00000000 --- a/packages/plugin-output-shared/src/BaseCLIOutputPlugin.ts +++ /dev/null @@ -1,405 +0,0 @@ -import type { - FastCommandPrompt, - OutputPluginContext, - OutputWriteContext, - RulePrompt, - RuleScope, - SkillPrompt, - SubAgentPrompt, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import type {AbstractOutputPluginOptions} from './AbstractOutputPlugin' -import * as path from 'node:path' -import {writeFileSync as deskWriteFileSync} from '@truenine/desk-paths' -import {mdxToMd} from '@truenine/md-compiler' -import {GlobalScopeCollector} from '@truenine/plugin-input-shared/scope' -import {AbstractOutputPlugin} from './AbstractOutputPlugin' -import {filterCommandsByProjectConfig, filterSkillsByProjectConfig, filterSubAgentsByProjectConfig} from './utils' - -export interface BaseCLIOutputPluginOptions extends AbstractOutputPluginOptions { - readonly commandsSubDir?: string - readonly agentsSubDir?: string - readonly skillsSubDir?: string - - readonly supportsFastCommands?: boolean - - readonly supportsSubAgents?: boolean - - readonly supportsSkills?: boolean - - readonly toolPreset?: string -} - -export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { - protected readonly commandsSubDir: string - protected readonly agentsSubDir: string - protected readonly skillsSubDir: string - protected readonly supportsFastCommands: boolean - protected readonly supportsSubAgents: boolean - protected readonly supportsSkills: boolean - protected readonly toolPreset?: string - - constructor(name: string, options: BaseCLIOutputPluginOptions) { - super(name, options) - this.commandsSubDir = options.commandsSubDir ?? 'commands' - this.agentsSubDir = options.agentsSubDir ?? 'agents' - this.skillsSubDir = options.skillsSubDir ?? 'skills' - this.supportsFastCommands = options.supportsFastCommands ?? true - this.supportsSubAgents = options.supportsSubAgents ?? true - this.supportsSkills = options.supportsSkills ?? true - if (options.toolPreset !== void 0) this.toolPreset = options.toolPreset - } - - async registerGlobalOutputDirs(_ctx: OutputPluginContext): Promise { - const globalDir = this.getGlobalConfigDir() - const results: RelativePath[] = [] - const subdirs: string[] = [] - - if (this.supportsFastCommands) subdirs.push(this.commandsSubDir) - if (this.supportsSubAgents) subdirs.push(this.agentsSubDir) - if (this.supportsSkills) subdirs.push(this.skillsSubDir) - - for (const subdir of subdirs) results.push(this.createRelativePath(subdir, globalDir, () => subdir)) - - return results - } - - async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - - const subdirs: string[] = [] // Subdirectories might be needed there too // Most CLI tools store project-local config in a hidden folder .toolname - if (this.supportsFastCommands) subdirs.push(this.commandsSubDir) - if (this.supportsSubAgents) subdirs.push(this.agentsSubDir) - if (this.supportsSkills) subdirs.push(this.skillsSubDir) - - if (subdirs.length === 0) return [] - - for (const project of projects) { - if (project.dirFromWorkspacePath == null) continue - - for (const subdir of subdirs) { - const dirPath = path.join(project.dirFromWorkspacePath.path, this.globalConfigDir, subdir) // Assuming globalConfigDir is something like .claude - results.push(this.createRelativePath(dirPath, project.dirFromWorkspacePath.basePath, () => subdir)) - } - } - - return results - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - - for (const project of projects) { - if (project.rootMemoryPrompt != null && project.dirFromWorkspacePath != null) { // Root memory file - results.push(this.createFileRelativePath(project.dirFromWorkspacePath, this.outputFileName)) - } - - if (project.childMemoryPrompts != null) { // Child memory files - for (const child of project.childMemoryPrompts) { - if (child.dir != null && this.isRelativePath(child.dir)) results.push(this.createFileRelativePath(child.dir, this.outputFileName)) - } - } - } - - return results - } - - async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const {globalMemory} = ctx.collectedInputContext - if (globalMemory == null) return [] - - const globalDir = this.getGlobalConfigDir() - const results: RelativePath[] = [ - this.createRelativePath(this.outputFileName, globalDir, () => this.globalConfigDir) - ] - - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const {fastCommands, subAgents, skills} = ctx.collectedInputContext - const transformOptions = {includeSeriesPrefix: true} as const - - if (this.supportsFastCommands && fastCommands != null) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) { - const fileName = this.transformFastCommandName(cmd, transformOptions) - results.push(this.createRelativePath(path.join(this.commandsSubDir, fileName), globalDir, () => this.commandsSubDir)) - } - } - - if (this.supportsSubAgents && subAgents != null) { - const filteredSubAgents = filterSubAgentsByProjectConfig(subAgents, projectConfig) - for (const agent of filteredSubAgents) { - const fileName = agent.dir.path.replace(/\.mdx$/, '.md') - results.push(this.createRelativePath(path.join(this.agentsSubDir, fileName), globalDir, () => this.agentsSubDir)) - } - } - - if (this.supportsSkills && skills == null) return results - if (skills == null) return results - - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - const skillDir = path.join(this.skillsSubDir, skillName) - - results.push(this.createRelativePath(path.join(skillDir, 'SKILL.md'), globalDir, () => skillName)) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - const refDocFileName = refDoc.dir.path.replace(/\.mdx$/, '.md') - const refDocPath = path.join(skillDir, refDocFileName) - results.push(this.createRelativePath(refDocPath, globalDir, () => skillName)) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) { - const resourcePath = path.join(skillDir, resource.relativePath) - results.push(this.createRelativePath(resourcePath, globalDir, () => skillName)) - } - } - } - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, globalMemory, fastCommands, subAgents, skills} = ctx.collectedInputContext - const hasProjectOutputs = workspace.projects.some( - p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 - ) - const hasGlobalMemory = globalMemory != null - const hasFastCommands = this.supportsFastCommands && (fastCommands?.length ?? 0) > 0 - const hasSubAgents = this.supportsSubAgents && (subAgents?.length ?? 0) > 0 - const hasSkills = this.supportsSkills && (skills?.length ?? 0) > 0 - - if (hasProjectOutputs || hasGlobalMemory || hasFastCommands || hasSubAgents || hasSkills) return true - - this.log.trace({action: 'skip', reason: 'noOutputs'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const {projects} = ctx.collectedInputContext.workspace - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - for (const project of projects) { - const projectName = project.name ?? 'unknown' - const projectDir = project.dirFromWorkspacePath - - if (projectDir == null) continue - - if (project.rootMemoryPrompt != null) { - const result = await this.writePromptFile(ctx, projectDir, project.rootMemoryPrompt.content as string, `project:${projectName}/root`) - fileResults.push(result) - } - - if (project.childMemoryPrompts != null) { - for (const child of project.childMemoryPrompts) { - const childResult = await this.writePromptFile(ctx, child.dir, child.content as string, `project:${projectName}/child:${child.workingChildDirectoryPath?.path ?? 'unknown'}`) - fileResults.push(childResult) - } - } - } - - return {files: fileResults, dirs: dirResults} - } - - async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {globalMemory} = ctx.collectedInputContext - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - const checkList = [ - {enabled: true, data: globalMemory}, - {enabled: this.supportsFastCommands, data: ctx.collectedInputContext.fastCommands}, - {enabled: this.supportsSubAgents, data: ctx.collectedInputContext.subAgents}, - {enabled: this.supportsSkills, data: ctx.collectedInputContext.skills} - ] - - if (checkList.every(item => !item.enabled || item.data == null)) return {files: fileResults, dirs: dirResults} - - const {fastCommands, subAgents, skills} = ctx.collectedInputContext - const globalDir = this.getGlobalConfigDir() - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - - if (globalMemory != null) { // Write Global Memory File - const fullPath = path.join(globalDir, this.outputFileName) - const relativePath: RelativePath = this.createRelativePath(this.outputFileName, globalDir, () => this.globalConfigDir) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'globalMemory', path: fullPath}) - fileResults.push({ - path: relativePath, - success: true, - skipped: false - }) - } else { - try { - deskWriteFileSync(fullPath, globalMemory.content as string) - this.log.trace({action: 'write', type: 'globalMemory', path: fullPath}) - fileResults.push({path: relativePath, success: true}) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalMemory', path: fullPath, error: errMsg}) - fileResults.push({path: relativePath, success: false, error: error as Error}) - } - } - } - - if (this.supportsFastCommands && fastCommands != null) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) { - const cmdResults = await this.writeFastCommand(ctx, globalDir, cmd) - fileResults.push(...cmdResults) - } - } - - if (this.supportsSubAgents && subAgents != null) { - const filteredSubAgents = filterSubAgentsByProjectConfig(subAgents, projectConfig) - for (const agent of filteredSubAgents) { - const agentResults = await this.writeSubAgent(ctx, globalDir, agent) - fileResults.push(...agentResults) - } - } - - if (this.supportsSkills && skills == null) return {files: fileResults, dirs: dirResults} - if (skills == null) return {files: fileResults, dirs: dirResults} - - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - for (const skill of filteredSkills) { - const skillResults = await this.writeSkill(ctx, globalDir, skill) - fileResults.push(...skillResults) - } - return {files: fileResults, dirs: dirResults} - } - - protected async writeFastCommand( - ctx: OutputWriteContext, - basePath: string, - cmd: FastCommandPrompt - ): Promise { - const transformOptions = this.getTransformOptionsFromContext(ctx) - const fileName = this.transformFastCommandName(cmd, transformOptions) - const targetDir = path.join(basePath, this.commandsSubDir) - const fullPath = path.join(targetDir, fileName) - - let compiledContent = cmd.content - let compiledFrontMatter = cmd.yamlFrontMatter - let useRecompiledFrontMatter = false - - if (cmd.rawMdxContent != null && this.toolPreset != null) { // Only recompile if we have raw content AND a tool preset is configured - this.log.debug('recompiling fast command with tool preset', { - file: cmd.dir.getAbsolutePath(), - toolPreset: this.toolPreset, - hasRawContent: true - }) - try { - // eslint-disable-next-line ts/no-unsafe-assignment - const scopeCollector = new GlobalScopeCollector({toolPreset: this.toolPreset as any}) // Cast to clean - const globalScope = scopeCollector.collect() - const result = await mdxToMd(cmd.rawMdxContent, {globalScope, extractMetadata: true, basePath: cmd.dir.basePath}) - compiledContent = result.content - compiledFrontMatter = result.metadata.fields as typeof cmd.yamlFrontMatter - useRecompiledFrontMatter = true - } - catch (e) { - this.log.warn('failed to recompile fast command, using default', { - file: cmd.dir.getAbsolutePath(), - error: e instanceof Error ? e.message : String(e) - }) - } - } - - const content = useRecompiledFrontMatter - ? this.buildMarkdownContent(compiledContent, compiledFrontMatter) - : this.buildMarkdownContentWithRaw(compiledContent, compiledFrontMatter, cmd.rawFrontMatter) - - return [await this.writeFile(ctx, fullPath, content, 'fastCommand')] - } - - protected async writeSubAgent( - ctx: OutputWriteContext, - basePath: string, - agent: SubAgentPrompt - ): Promise { - const fileName = agent.dir.path.replace(/\.mdx$/, '.md') - const targetDir = path.join(basePath, this.agentsSubDir) - const fullPath = path.join(targetDir, fileName) - - const content = this.buildMarkdownContentWithRaw( - agent.content, - agent.yamlFrontMatter, - agent.rawFrontMatter - ) - - return [await this.writeFile(ctx, fullPath, content, 'subAgent')] - } - - protected async writeSkill( - ctx: OutputWriteContext, - basePath: string, - skill: SkillPrompt - ): Promise { - const results: WriteResult[] = [] - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - const targetDir = path.join(basePath, this.skillsSubDir, skillName) - const fullPath = path.join(targetDir, 'SKILL.md') - - const content = this.buildMarkdownContentWithRaw( - skill.content as string, - skill.yamlFrontMatter, - skill.rawFrontMatter - ) - - const mainFileResult = await this.writeFile(ctx, fullPath, content, 'skill') - results.push(mainFileResult) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - const refResults = await this.writeSkillReferenceDocument(ctx, targetDir, skillName, refDoc, basePath) - results.push(...refResults) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) { - const refResults = await this.writeSkillResource(ctx, targetDir, skillName, resource, basePath) - results.push(...refResults) - } - } - - return results - } - - protected async writeSkillReferenceDocument( - ctx: OutputWriteContext, - skillDir: string, - _skillName: string, - refDoc: {dir: RelativePath, content: unknown}, - _basePath: string - ): Promise { - const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') - const fullPath = path.join(skillDir, fileName) - return [await this.writeFile(ctx, fullPath, refDoc.content as string, 'skillRefDoc')] - } - - protected async writeSkillResource( - ctx: OutputWriteContext, - skillDir: string, - _skillName: string, - resource: {relativePath: string, content: string}, - _basePath: string - ): Promise { - const fullPath = path.join(skillDir, resource.relativePath) - return [await this.writeFile(ctx, fullPath, resource.content, 'skillResource')] - } - - protected override normalizeRuleScope(rule: RulePrompt): RuleScope { - return rule.scope ?? 'project' - } -} diff --git a/packages/plugin-output-shared/src/index.ts b/packages/plugin-output-shared/src/index.ts deleted file mode 100644 index 00e937bd..00000000 --- a/packages/plugin-output-shared/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { - AbstractOutputPlugin -} from './AbstractOutputPlugin' -export type { - AbstractOutputPluginOptions, - CombineOptions, - FastCommandNameTransformOptions -} from './AbstractOutputPlugin' -export { - BaseCLIOutputPlugin -} from './BaseCLIOutputPlugin' -export type { - BaseCLIOutputPluginOptions -} from './BaseCLIOutputPlugin' diff --git a/packages/plugin-output-shared/src/registry/RegistryWriter.ts b/packages/plugin-output-shared/src/registry/RegistryWriter.ts deleted file mode 100644 index 3721cba1..00000000 --- a/packages/plugin-output-shared/src/registry/RegistryWriter.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Registry Configuration Writer - * - * Abstract base class for registry configuration writers. - * Provides common functionality for reading, writing, and merging JSON registry files. - * - * @see Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 7.1, 7.2 - */ - -import type {ILogger} from '@truenine/plugin-shared' -import type {RegistryData, RegistryOperationResult} from '@truenine/plugin-shared/types' - -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' - -import {createLogger} from '@truenine/plugin-shared' - -/** - * Abstract base class for registry configuration writers. - * Provides common functionality for reading, writing, and merging JSON registry files. - * - * @template TEntry - The type of entries stored in the registry - * @template TRegistry - The full registry data structure type - * - * @see Requirements 1.1, 1.2, 1.3, 1.7 - */ -export abstract class RegistryWriter< - TEntry, - TRegistry extends RegistryData = RegistryData -> { - protected readonly registryPath: string - - protected readonly log: ILogger - - protected constructor(registryPath: string, logger?: ILogger) { - this.registryPath = this.resolvePath(registryPath) - this.log = logger ?? createLogger(this.constructor.name) - } - - protected resolvePath(p: string): string { - if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1)) - return path.resolve(p) - } - - protected getRegistryDir(): string { - return path.dirname(this.registryPath) - } - - protected ensureRegistryDir(): void { - const dir = this.getRegistryDir() - if (!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true}) - } - - read(): TRegistry { - if (!fs.existsSync(this.registryPath)) { - this.log.debug('registry not found', {path: this.registryPath}) - return this.createInitialRegistry() - } - - try { - const content = fs.readFileSync(this.registryPath, 'utf8') - return JSON.parse(content) as TRegistry - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error('parse failed', {path: this.registryPath, error: errMsg}) - return this.createInitialRegistry() - } - } - - protected write(data: TRegistry, dryRun?: boolean): boolean { - const updatedData = { // Update lastUpdated timestamp - ...data, - lastUpdated: new Date().toISOString() - } as TRegistry - - if (dryRun === true) { - this.log.trace({action: 'dryRun', type: 'registry', path: this.registryPath}) - return true - } - - const tempPath = `${this.registryPath}.tmp.${Date.now()}` - - try { - this.ensureRegistryDir() - - const content = JSON.stringify(updatedData, null, 2) // Write to temporary file first - fs.writeFileSync(tempPath, content, 'utf8') - - fs.renameSync(tempPath, this.registryPath) // Atomic rename to replace target - - this.log.trace({action: 'write', type: 'registry', path: this.registryPath}) - return true - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'registry', path: this.registryPath, error: errMsg}) - - try { // Cleanup temp file if it exists - if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath) - } - catch { - } // Ignore cleanup errors - - return false - } - } - - register( - entries: readonly TEntry[], - dryRun?: boolean - ): readonly RegistryOperationResult[] { - const results: RegistryOperationResult[] = [] - - const existing = this.read() // Read existing registry - - const merged = this.merge(existing, entries) // Merge new entries - - const writeSuccess = this.write(merged, dryRun) // Write updated registry - - for (const entry of entries) { // Build results for each entry - const entryName = this.getEntryName(entry) - if (writeSuccess) { - results.push({success: true, entryName}) - if (dryRun === true) this.log.trace({action: 'dryRun', type: 'registerEntry', entryName}) - else this.log.trace({action: 'register', type: 'entry', entryName}) - } else { - results.push({success: false, entryName, error: new Error(`Failed to write registry file`)}) - this.log.error('register entry failed', {entryName}) - } - } - - return results - } - - protected generateEntryId(prefix?: string): string { - const timestamp = Date.now() - const random = Math.random().toString(36).slice(2, 8) - const id = `${timestamp}-${random}` - return prefix != null ? `${prefix}-${id}` : id - } - - protected abstract getEntryName(entry: TEntry): string - - protected abstract merge(existing: TRegistry, entries: readonly TEntry[]): TRegistry - - protected abstract createInitialRegistry(): TRegistry -} diff --git a/packages/plugin-output-shared/src/registry/index.ts b/packages/plugin-output-shared/src/registry/index.ts deleted file mode 100644 index 658667cd..00000000 --- a/packages/plugin-output-shared/src/registry/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - RegistryWriter -} from './RegistryWriter' diff --git a/packages/plugin-output-shared/src/utils/commandFilter.ts b/packages/plugin-output-shared/src/utils/commandFilter.ts deleted file mode 100644 index f3446593..00000000 --- a/packages/plugin-output-shared/src/utils/commandFilter.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type {FastCommandPrompt} from '@truenine/plugin-shared' -import type {ProjectConfig} from '@truenine/plugin-shared/types' -import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' - -export function filterCommandsByProjectConfig( - commands: readonly FastCommandPrompt[], - projectConfig: ProjectConfig | undefined -): readonly FastCommandPrompt[] { - const effectiveSeries = resolveEffectiveIncludeSeries(projectConfig?.includeSeries, projectConfig?.commands?.includeSeries) - return commands.filter(command => matchesSeries(command.seriName, effectiveSeries)) -} diff --git a/packages/plugin-output-shared/src/utils/gitUtils.ts b/packages/plugin-output-shared/src/utils/gitUtils.ts deleted file mode 100644 index eace5421..00000000 --- a/packages/plugin-output-shared/src/utils/gitUtils.ts +++ /dev/null @@ -1,121 +0,0 @@ -import * as fs from 'node:fs' -import * as path from 'node:path' - -/** - * Resolves the actual `.git/info` directory for a given project path. - * Handles both regular git repos (`.git` is a directory) and submodules/worktrees (`.git` is a file with `gitdir:` pointer). - * Returns `null` if no valid git info directory can be resolved. - */ -export function resolveGitInfoDir(projectDir: string): string | null { - const dotGitPath = path.join(projectDir, '.git') - - if (!fs.existsSync(dotGitPath)) return null - - const stat = fs.lstatSync(dotGitPath) - - if (stat.isDirectory()) { - const infoDir = path.join(dotGitPath, 'info') - return infoDir - } - - if (stat.isFile()) { - try { - const content = fs.readFileSync(dotGitPath, 'utf8').trim() - const match = /^gitdir: (.+)$/.exec(content) - if (match?.[1] != null) { - const gitdir = path.resolve(projectDir, match[1]) - return path.join(gitdir, 'info') - } - } - catch { /* ignore read errors */ } - } - - return null -} - -/** - * Recursively discovers all `.git` entries (directories or files) under a given root, - * skipping common non-source directories. - * Returns absolute paths of directories containing a `.git` entry. - */ -export function findAllGitRepos(rootDir: string, maxDepth = 5): string[] { - const results: string[] = [] - const SKIP_DIRS = new Set(['node_modules', '.turbo', 'dist', 'build', 'out', '.cache']) - - function walk(dir: string, depth: number): void { - if (depth > maxDepth) return - - let entries: fs.Dirent[] - try { - const raw = fs.readdirSync(dir, {withFileTypes: true}) - if (!Array.isArray(raw)) return - entries = raw - } - catch { return } - - const hasGit = entries.some(e => e.name === '.git') - if (hasGit && dir !== rootDir) results.push(dir) - - for (const entry of entries) { - if (!entry.isDirectory()) continue - if (entry.name === '.git' || SKIP_DIRS.has(entry.name)) continue - walk(path.join(dir, entry.name), depth + 1) - } - } - - walk(rootDir, 0) - return results -} - -/** - * Scans `.git/modules/` directory recursively to find all submodule `info/` dirs. - * Handles nested submodules (modules within modules). - * Returns absolute paths of `info/` directories. - */ -export function findGitModuleInfoDirs(dotGitDir: string): string[] { - const modulesDir = path.join(dotGitDir, 'modules') - if (!fs.existsSync(modulesDir)) return [] - - const results: string[] = [] - - function walk(dir: string): void { - let entries: fs.Dirent[] - try { - const raw = fs.readdirSync(dir, {withFileTypes: true}) - if (!Array.isArray(raw)) return - entries = raw - } - catch { return } - - const hasInfo = entries.some(e => e.name === 'info' && e.isDirectory()) - if (hasInfo) results.push(path.join(dir, 'info')) - - const nestedModules = entries.find(e => e.name === 'modules' && e.isDirectory()) - 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)) - } - } - - let topEntries: fs.Dirent[] - try { - const raw = fs.readdirSync(modulesDir, {withFileTypes: true}) - if (!Array.isArray(raw)) return results - topEntries = raw - } - catch { return results } - - for (const entry of topEntries) { - if (entry.isDirectory()) walk(path.join(modulesDir, entry.name)) - } - - return results -} diff --git a/packages/plugin-output-shared/src/utils/index.ts b/packages/plugin-output-shared/src/utils/index.ts deleted file mode 100644 index 0fc6db46..00000000 --- a/packages/plugin-output-shared/src/utils/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -export { - filterCommandsByProjectConfig -} from './commandFilter' -export { - findAllGitRepos, - findGitModuleInfoDirs, - resolveGitInfoDir -} from './gitUtils' -export { - applySubSeriesGlobPrefix, - filterRulesByProjectConfig, - getGlobalRules, - getProjectRules -} from './ruleFilter' -export { - matchesSeries, - resolveEffectiveIncludeSeries, - resolveSubSeries -} from './seriesFilter' -export { - filterSkillsByProjectConfig -} from './skillFilter' -export { - filterSubAgentsByProjectConfig -} from './subAgentFilter' diff --git a/packages/plugin-output-shared/src/utils/pathNormalization.property.test.ts b/packages/plugin-output-shared/src/utils/pathNormalization.property.test.ts deleted file mode 100644 index 514700d3..00000000 --- a/packages/plugin-output-shared/src/utils/pathNormalization.property.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** Property 4: SubSeries path normalization idempotence. Validates: Requirement 5.4 */ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -import {normalizeSubdirPath} from './ruleFilter' - -const pathArb = fc.stringMatching(/^[./_a-z0-9-]{0,40}$/) - -const subdirPathArb = fc.oneof( - fc.constant('./foo/'), - fc.constant('foo/'), - fc.constant('./foo'), - fc.constant('foo'), - fc.constant(''), - fc.constant('./'), - fc.constant('.//foo//'), - fc.constant('././foo///'), - fc.constant('./a/b/c/'), - pathArb -) - -describe('property 4: subSeries path normalization idempotence', () => { - it('normalize(normalize(p)) === normalize(p) for arbitrary path strings', () => { // **Validates: Requirement 5.4** - fc.assert( - fc.property(subdirPathArb, p => { - const once = normalizeSubdirPath(p) - const twice = normalizeSubdirPath(once) - expect(twice).toBe(once) - }), - {numRuns: 200} - ) - }) - - it('result never starts with ./', () => { // **Validates: Requirement 5.4** - fc.assert( - fc.property(subdirPathArb, p => { - const result = normalizeSubdirPath(p) - expect(result.startsWith('./')).toBe(false) - }), - {numRuns: 200} - ) - }) - - it('result never ends with /', () => { // **Validates: Requirement 5.4** - fc.assert( - fc.property(subdirPathArb, p => { - const result = normalizeSubdirPath(p) - expect(result.endsWith('/')).toBe(false) - }), - {numRuns: 200} - ) - }) - - it('empty string stays empty', () => { // **Validates: Requirement 5.4** - expect(normalizeSubdirPath('')).toBe('') - }) -}) diff --git a/packages/plugin-output-shared/src/utils/ruleFilter.ts b/packages/plugin-output-shared/src/utils/ruleFilter.ts deleted file mode 100644 index 117e2ea0..00000000 --- a/packages/plugin-output-shared/src/utils/ruleFilter.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type {RulePrompt} from '@truenine/plugin-shared' -import type {Project, ProjectConfig} from '@truenine/plugin-shared/types' -import {matchesSeries, resolveEffectiveIncludeSeries, resolveSubSeries} from './seriesFilter' - -export function normalizeSubdirPath(subdir: string): string { - let normalized = subdir.replaceAll(/\.\/+/g, '') - normalized = normalized.replaceAll(/\/+$/g, '') - return normalized -} - -function smartConcatGlob(prefix: string, glob: string): string { - if (glob.startsWith('**/')) return `${prefix}/${glob}` - if (glob.startsWith('*')) return `${prefix}/**/${glob}` - return `${prefix}/${glob}` -} - -function extractPrefixAndBaseGlob(glob: string, prefixes: readonly string[]): {prefix: string | null, baseGlob: string} { - for (const prefix of prefixes) { - const normalizedPrefix = prefix.replaceAll(/\/+$/g, '') - const patterns = [ - {prefix: normalizedPrefix, pattern: `${normalizedPrefix}/`}, - {prefix: normalizedPrefix, pattern: `${normalizedPrefix}\\`} - ] - for (const {prefix: p, pattern} of patterns) { - if (glob.startsWith(pattern)) return {prefix: p, baseGlob: glob.slice(pattern.length)} - } - if (glob === normalizedPrefix) return {prefix: normalizedPrefix, baseGlob: '**/*'} - } - return {prefix: null, baseGlob: glob} -} - -export function applySubSeriesGlobPrefix( - rules: readonly RulePrompt[], - projectConfig: ProjectConfig | undefined -): readonly RulePrompt[] { - const subSeries = resolveSubSeries(projectConfig?.subSeries, projectConfig?.rules?.subSeries) - if (Object.keys(subSeries).length === 0) return rules - - const normalizedSubSeries: Record = {} - for (const [subdir, seriNames] of Object.entries(subSeries)) { - const normalizedSubdir = normalizeSubdirPath(subdir) - normalizedSubSeries[normalizedSubdir] = seriNames - } - - const allPrefixes = Object.keys(normalizedSubSeries) - - return rules.map(rule => { - if (rule.seriName == null) return rule - - const matchedPrefixes: string[] = [] - for (const [subdir, seriNames] of Object.entries(normalizedSubSeries)) { - const matched = Array.isArray(rule.seriName) - ? rule.seriName.some(name => seriNames.includes(name)) - : seriNames.includes(rule.seriName) - if (matched) matchedPrefixes.push(subdir) - } - - if (matchedPrefixes.length === 0) return rule - - const newGlobs: string[] = [] - for (const originalGlob of rule.globs) { - const {prefix: existingPrefix, baseGlob} = extractPrefixAndBaseGlob(originalGlob, allPrefixes) - - if (existingPrefix != null) newGlobs.push(originalGlob) - - for (const prefix of matchedPrefixes) { - if (prefix === existingPrefix) continue - const newGlob = smartConcatGlob(prefix, baseGlob) - if (!newGlobs.includes(newGlob)) newGlobs.push(newGlob) - } - } - - return { - ...rule, - globs: newGlobs - } - }) -} - -export function filterRulesByProjectConfig( - rules: readonly RulePrompt[], - projectConfig: ProjectConfig | undefined -): readonly RulePrompt[] { - const effectiveSeries = resolveEffectiveIncludeSeries(projectConfig?.includeSeries, projectConfig?.rules?.includeSeries) - return rules.filter(rule => matchesSeries(rule.seriName, effectiveSeries)) -} - -function normalizeRuleScope(rule: RulePrompt): string { - return rule.scope ?? 'project' -} - -/** - * Returns project-scoped rules for a given project, with sub-series glob prefix applied. - */ -export function getProjectRules(rules: readonly RulePrompt[], project: Project): readonly RulePrompt[] { - const projectRules = rules.filter(r => normalizeRuleScope(r) === 'project') - return applySubSeriesGlobPrefix(filterRulesByProjectConfig(projectRules, project.projectConfig), project.projectConfig) -} - -/** - * Returns global-scoped rules from the given rule list. - */ -export function getGlobalRules(rules: readonly RulePrompt[]): readonly RulePrompt[] { - return rules.filter(r => normalizeRuleScope(r) === 'global') -} diff --git a/packages/plugin-output-shared/src/utils/seriesFilter.napi-equivalence.property.test.ts b/packages/plugin-output-shared/src/utils/seriesFilter.napi-equivalence.property.test.ts deleted file mode 100644 index 72df74a6..00000000 --- a/packages/plugin-output-shared/src/utils/seriesFilter.napi-equivalence.property.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** Property 5: NAPI and TypeScript behavioral equivalence. Validates: Requirement 6.4 */ -import * as napiConfig from '@truenine/config' -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -const napiAvailable = typeof napiConfig.matchesSeries === 'function' - && typeof napiConfig.resolveEffectiveIncludeSeries === 'function' - && typeof napiConfig.resolveSubSeries === 'function' - -function resolveEffectiveIncludeSeriesTS(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] { - if (topLevel == null && typeSpecific == null) return [] - return [...new Set([...topLevel ?? [], ...typeSpecific ?? []])] -} - -function matchesSeriesTS(seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]): boolean { - if (seriName == null) return true - if (effectiveIncludeSeries.length === 0) return true - if (typeof seriName === 'string') return effectiveIncludeSeries.includes(seriName) - return seriName.some(name => effectiveIncludeSeries.includes(name)) -} - -function resolveSubSeriesTS( - topLevel?: Readonly>, - typeSpecific?: Readonly> -): Record { - if (topLevel == null && typeSpecific == null) return {} - const merged: Record = {} - for (const [key, values] of Object.entries(topLevel ?? {})) merged[key] = [...values] - for (const [key, values] of Object.entries(typeSpecific ?? {})) { - merged[key] = Object.hasOwn(merged, key) ? [...new Set([...merged[key]!, ...values])] : [...values] - } - return merged -} - -const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) - -const optionalSeriesArb = fc.option(fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), {nil: void 0}) - -const seriNameArb: fc.Arbitrary = fc.oneof( - fc.constant(null), - fc.constant(void 0), - seriesNameArb, - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) -) - -const subSeriesRecordArb = fc.option( - fc.dictionary(seriesNameArb, fc.array(seriesNameArb, {minLength: 0, maxLength: 5})), - {nil: void 0} -) - -function sortedArray(arr: readonly string[]): string[] { - return [...arr].sort() -} - -function sortedRecord(rec: Readonly>): Record { - const out: Record = {} - for (const key of Object.keys(rec).sort()) out[key] = [...new Set(rec[key])].sort() - return out -} - -describe.skipIf(!napiAvailable)('property 5: NAPI and TypeScript behavioral equivalence', () => { - it('resolveEffectiveIncludeSeries: NAPI and TS produce same set', () => { // **Validates: Requirement 6.4** - fc.assert( - fc.property( - optionalSeriesArb, - optionalSeriesArb, - (topLevel, typeSpecific) => { - const napiResult = napiConfig.resolveEffectiveIncludeSeries(topLevel, typeSpecific) - const tsResult = resolveEffectiveIncludeSeriesTS(topLevel, typeSpecific) - expect(sortedArray(napiResult)).toEqual(sortedArray(tsResult)) - } - ), - {numRuns: 200} - ) - }) - - it('matchesSeries: NAPI and TS produce identical boolean', () => { // **Validates: Requirement 6.4** - fc.assert( - fc.property( - seriNameArb, - fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), - (seriName, list) => { - const napiResult = napiConfig.matchesSeries(seriName, list) - const tsResult = matchesSeriesTS(seriName, list) - expect(napiResult).toBe(tsResult) - } - ), - {numRuns: 200} - ) - }) - - it('resolveSubSeries: NAPI and TS produce same merged record', () => { // **Validates: Requirement 6.4** - fc.assert( - fc.property( - subSeriesRecordArb, - subSeriesRecordArb, - (topLevel, typeSpecific) => { - const napiResult = napiConfig.resolveSubSeries(topLevel, typeSpecific) - const tsResult = resolveSubSeriesTS(topLevel, typeSpecific) - expect(sortedRecord(napiResult)).toEqual(sortedRecord(tsResult)) - } - ), - {numRuns: 200} - ) - }) -}) diff --git a/packages/plugin-output-shared/src/utils/seriesFilter.property.test.ts b/packages/plugin-output-shared/src/utils/seriesFilter.property.test.ts deleted file mode 100644 index 57fe292d..00000000 --- a/packages/plugin-output-shared/src/utils/seriesFilter.property.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' - -/** Property 1: Effective IncludeSeries is the set union. Validates: Requirements 3.1, 3.2, 3.3, 3.4 */ -describe('resolveEffectiveIncludeSeries property tests', () => { - const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s)) - - const optionalSeriesArb = fc.option(fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), {nil: void 0}) - - it('property 1: result is the set union of both inputs, undefined treated as empty', () => { // **Validates: Requirements 3.1, 3.2, 3.3, 3.4** - fc.assert( - fc.property( - optionalSeriesArb, - optionalSeriesArb, - (topLevel, typeSpecific) => { - const result = resolveEffectiveIncludeSeries(topLevel, typeSpecific) - const expectedUnion = new Set([...topLevel ?? [], ...typeSpecific ?? []]) - - for (const item of result) expect(expectedUnion.has(item)).toBe(true) // every result element comes from an input - for (const item of expectedUnion) expect(result).toContain(item) // every input element is in the result - expect(result.length).toBe(new Set(result).size) // no duplicates - } - ), - {numRuns: 200} - ) - }) - - it('property 1: both undefined yields empty array', () => { // **Validates: Requirement 3.4** - const result = resolveEffectiveIncludeSeries(void 0, void 0) - expect(result).toEqual([]) - }) - - it('property 1: only top-level defined yields top-level (deduplicated)', () => { // **Validates: Requirement 3.2** - fc.assert( - fc.property( - fc.array(seriesNameArb, {minLength: 1, maxLength: 10}), - topLevel => { - const result = resolveEffectiveIncludeSeries(topLevel, void 0) - const expected = [...new Set(topLevel)] - expect(result).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) - - it('property 1: only type-specific defined yields type-specific (deduplicated)', () => { // **Validates: Requirement 3.3** - fc.assert( - fc.property( - fc.array(seriesNameArb, {minLength: 1, maxLength: 10}), - typeSpecific => { - const result = resolveEffectiveIncludeSeries(void 0, typeSpecific) - const expected = [...new Set(typeSpecific)] - expect(result).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) -}) - -/** Property 2: Series matching correctness. Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5 */ -describe('matchesSeries property tests', () => { - const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s)) - - const nonEmptySeriesListArb = fc.array(seriesNameArb, {minLength: 1, maxLength: 10}) - .map(arr => [...new Set(arr)]) - .filter(arr => arr.length > 0) - - const seriNameArb: fc.Arbitrary = fc.oneof( - fc.constant(null), - fc.constant(void 0), - seriesNameArb, - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) - ) - - it('property 2: null/undefined seriName is always included regardless of list', () => { // **Validates: Requirements 4.1** - fc.assert( - fc.property( - fc.oneof(fc.constant(null), fc.constant(void 0)), - nonEmptySeriesListArb, - (seriName, list) => { expect(matchesSeries(seriName, list)).toBe(true) } - ), - {numRuns: 200} - ) - }) - - it('property 2: empty effectiveIncludeSeries includes all seriName values', () => { // **Validates: Requirements 4.4** - fc.assert( - fc.property( - seriNameArb, - seriName => { expect(matchesSeries(seriName, [])).toBe(true) } - ), - {numRuns: 200} - ) - }) - - it('property 2: string seriName included iff it is a member of the list', () => { // **Validates: Requirements 4.2, 4.5** - fc.assert( - fc.property( - seriesNameArb, - nonEmptySeriesListArb, - (seriName, list) => { - const result = matchesSeries(seriName, list) - const expected = list.includes(seriName) - expect(result).toBe(expected) - } - ), - {numRuns: 200} - ) - }) - - it('property 2: array seriName included iff intersection with list is non-empty', () => { // **Validates: Requirements 4.3** - fc.assert( - fc.property( - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}), - nonEmptySeriesListArb, - (seriNameArr, list) => { - const result = matchesSeries(seriNameArr, list) - const hasIntersection = seriNameArr.some(n => list.includes(n)) - expect(result).toBe(hasIntersection) - } - ), - {numRuns: 200} - ) - }) - - it('property 2: combined — all seriName variants obey spec rules', () => { // **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5** - fc.assert( - fc.property( - seriNameArb, - fc.oneof(fc.constant([] as string[]), nonEmptySeriesListArb), - (seriName, list) => { - const result = matchesSeries(seriName, list) - - if (seriName == null) { - expect(result).toBe(true) // 4.1 - } else if (list.length === 0) { - expect(result).toBe(true) // 4.4 - } else if (typeof seriName === 'string') { - expect(result).toBe(list.includes(seriName)) // 4.2, 4.5 - } else { - expect(result).toBe(seriName.some(n => list.includes(n))) // 4.3 - } - } - ), - {numRuns: 300} - ) - }) -}) diff --git a/packages/plugin-output-shared/src/utils/seriesFilter.ts b/packages/plugin-output-shared/src/utils/seriesFilter.ts deleted file mode 100644 index d82f4922..00000000 --- a/packages/plugin-output-shared/src/utils/seriesFilter.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** Core series filtering helpers. Delegates to Rust NAPI via `@truenine/config` when available, falls back to pure-TS implementations otherwise. */ -import {createRequire} from 'node:module' - -function resolveEffectiveIncludeSeriesTS(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] { - if (topLevel == null && typeSpecific == null) return [] - return [...new Set([...topLevel ?? [], ...typeSpecific ?? []])] -} - -function matchesSeriesTS(seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]): boolean { - if (seriName == null) return true - if (effectiveIncludeSeries.length === 0) return true - if (typeof seriName === 'string') return effectiveIncludeSeries.includes(seriName) - return seriName.some(name => effectiveIncludeSeries.includes(name)) -} - -function resolveSubSeriesTS( - topLevel?: Readonly>, - typeSpecific?: Readonly> -): Record { - if (topLevel == null && typeSpecific == null) return {} - const merged: Record = {} - for (const [key, values] of Object.entries(topLevel ?? {})) merged[key] = [...values] - for (const [key, values] of Object.entries(typeSpecific ?? {})) { - merged[key] = Object.hasOwn(merged, key) ? [...new Set([...merged[key]!, ...values])] : [...values] - } - return merged -} - -interface SeriesFilterFns { - resolveEffectiveIncludeSeries: typeof resolveEffectiveIncludeSeriesTS - matchesSeries: typeof matchesSeriesTS - resolveSubSeries: typeof resolveSubSeriesTS -} - -function tryLoadNapi(): SeriesFilterFns | undefined { - try { - const _require = createRequire(import.meta.url) - const napi = _require('@truenine/config') as SeriesFilterFns - if (typeof napi.matchesSeries === 'function' - && typeof napi.resolveEffectiveIncludeSeries === 'function' - && typeof napi.resolveSubSeries === 'function') return napi - } - catch { /* NAPI unavailable — pure-TS fallback will be used */ } - return void 0 -} - -const { - resolveEffectiveIncludeSeries, - matchesSeries, - resolveSubSeries -}: SeriesFilterFns = tryLoadNapi() ?? { - resolveEffectiveIncludeSeries: resolveEffectiveIncludeSeriesTS, - matchesSeries: matchesSeriesTS, - resolveSubSeries: resolveSubSeriesTS -} - -export { - matchesSeries, - resolveEffectiveIncludeSeries, - resolveSubSeries -} diff --git a/packages/plugin-output-shared/src/utils/skillFilter.ts b/packages/plugin-output-shared/src/utils/skillFilter.ts deleted file mode 100644 index 6f09a457..00000000 --- a/packages/plugin-output-shared/src/utils/skillFilter.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type {SkillPrompt} from '@truenine/plugin-shared' -import type {ProjectConfig} from '@truenine/plugin-shared/types' -import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' - -export function filterSkillsByProjectConfig( - skills: readonly SkillPrompt[], - projectConfig: ProjectConfig | undefined -): readonly SkillPrompt[] { - const effectiveSeries = resolveEffectiveIncludeSeries(projectConfig?.includeSeries, projectConfig?.skills?.includeSeries) - return skills.filter(skill => matchesSeries(skill.seriName, effectiveSeries)) -} diff --git a/packages/plugin-output-shared/src/utils/subAgentFilter.ts b/packages/plugin-output-shared/src/utils/subAgentFilter.ts deleted file mode 100644 index 204e5223..00000000 --- a/packages/plugin-output-shared/src/utils/subAgentFilter.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type {SubAgentPrompt} from '@truenine/plugin-shared' -import type {ProjectConfig} from '@truenine/plugin-shared/types' -import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' - -export function filterSubAgentsByProjectConfig( - subAgents: readonly SubAgentPrompt[], - projectConfig: ProjectConfig | undefined -): readonly SubAgentPrompt[] { - const effectiveSeries = resolveEffectiveIncludeSeries(projectConfig?.includeSeries, projectConfig?.subAgents?.includeSeries) - return subAgents.filter(subAgent => matchesSeries(subAgent.seriName, effectiveSeries)) -} diff --git a/packages/plugin-output-shared/src/utils/subSeriesGlobExpansion.property.test.ts b/packages/plugin-output-shared/src/utils/subSeriesGlobExpansion.property.test.ts deleted file mode 100644 index a9fadb1d..00000000 --- a/packages/plugin-output-shared/src/utils/subSeriesGlobExpansion.property.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** Property 3: SubSeries glob expansion. Validates: Requirements 5.1, 5.2, 5.3 */ -import type {RulePrompt} from '@truenine/plugin-shared' -import type {ProjectConfig} from '@truenine/plugin-shared/types' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -import {applySubSeriesGlobPrefix} from './ruleFilter' - -const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) - -const seriNameArb: fc.Arbitrary = fc.oneof( - fc.constant(null), - fc.constant(void 0), - seriesNameArb, - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) -) - -const globGen = fc.stringMatching(/^\*\*\/\*\.[a-z]{1,5}$/) -const globArrayGen = fc.array(globGen, {minLength: 1, maxLength: 5}) -const subdirGen = fc.stringMatching(/^[a-z][a-z0-9/-]{0,30}$/) - .filter(s => !s.endsWith('/') && !s.includes('//')) - -function createMockRulePrompt(seriName: string | string[] | null | undefined, globs: readonly string[] = ['**/*.ts']): RulePrompt { - const content = '# Rule body' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: {pathKind: FilePathKind.Relative, path: '.', basePath: '', getDirectoryName: () => '.', getAbsolutePath: () => '.'}, - markdownContents: [], - yamlFrontMatter: {description: 'Test rule', globs: [...globs]}, - series: 'test', - ruleName: 'test-rule', - globs: [...globs], - scope: 'project', - seriName - } as unknown as RulePrompt -} - -describe('property 3: subSeries glob expansion', () => { - it('rules without seriName have unchanged globs', () => { // **Validates: Requirements 5.2** - fc.assert( - fc.property( - globArrayGen, - subdirGen, - fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), - (globs, subdir, seriNames) => { - const rule = createMockRulePrompt(null, globs) - const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - expect(result[0]!.globs).toEqual(globs) - } - ), - {numRuns: 200} - ) - }) - - it('rules with undefined seriName have unchanged globs', () => { // **Validates: Requirements 5.2** - fc.assert( - fc.property( - globArrayGen, - subdirGen, - fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), - (globs, subdir, seriNames) => { - const rule = createMockRulePrompt(void 0, globs) - const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - expect(result[0]!.globs).toEqual(globs) - } - ), - {numRuns: 200} - ) - }) - - it('string seriName matching subSeries expands globs with subdir prefix', () => { // **Validates: Requirements 5.1** - fc.assert( - fc.property( - seriesNameArb, - globArrayGen, - subdirGen, - (seriName, globs, subdir) => { - const rule = createMockRulePrompt(seriName, globs) - const config: ProjectConfig = {subSeries: {[subdir]: [seriName]}} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - const resultGlobs = result[0]!.globs - for (const g of resultGlobs) expect(g).toContain(subdir) // every expanded glob contains the subdir prefix - } - ), - {numRuns: 200} - ) - }) - - it('array seriName matching subSeries expands globs for all matching subdirs', () => { // **Validates: Requirements 5.1, 5.3** - fc.assert( - fc.property( - seriesNameArb, - globArrayGen, - fc.array(subdirGen, {minLength: 2, maxLength: 4}).filter(arr => new Set(arr).size === arr.length), - (seriName, globs, subdirs) => { - const rule = createMockRulePrompt([seriName], globs) - const subSeries: Record = {} // each subdir maps to the same seriName - for (const sd of subdirs) subSeries[sd] = [seriName] - const config: ProjectConfig = {subSeries} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - const resultGlobs = result[0]!.globs - for (const sd of subdirs) expect(resultGlobs.some(g => g.includes(sd))).toBe(true) // every subdir appears in at least one expanded glob - } - ), - {numRuns: 200} - ) - }) - - it('non-matching seriName leaves globs unchanged', () => { // **Validates: Requirements 5.2** - fc.assert( - fc.property( - seriesNameArb, - seriesNameArb, - globArrayGen, - subdirGen, - (ruleSeriName, subSeriesSeriName, globs, subdir) => { - fc.pre(ruleSeriName !== subSeriesSeriName) - const rule = createMockRulePrompt(ruleSeriName, globs) - const config: ProjectConfig = {subSeries: {[subdir]: [subSeriesSeriName]}} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - expect(result[0]!.globs).toEqual(globs) - } - ), - {numRuns: 200} - ) - }) - - it('rule count is preserved', () => { // **Validates: Requirements 5.1, 5.2, 5.3** - fc.assert( - fc.property( - fc.array(fc.tuple(seriNameArb, globArrayGen), {minLength: 0, maxLength: 10}), - subdirGen, - fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), - (ruleSpecs, subdir, seriNames) => { - const rules = ruleSpecs.map(([sn, gl]) => createMockRulePrompt(sn, gl)) - const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} - const result = applySubSeriesGlobPrefix(rules, config) - expect(result).toHaveLength(rules.length) - } - ), - {numRuns: 200} - ) - }) - - it('deterministic: same input produces same output', () => { // **Validates: Requirements 5.1, 5.2, 5.3** - fc.assert( - fc.property( - seriNameArb, - globArrayGen, - subdirGen, - fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), - (seriName, globs, subdir, seriNames) => { - const rules = [createMockRulePrompt(seriName, globs)] - const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} - const result1 = applySubSeriesGlobPrefix(rules, config) - const result2 = applySubSeriesGlobPrefix(rules, config) - expect(result1).toEqual(result2) - } - ), - {numRuns: 200} - ) - }) - - it('at least one glob per matched subdir when matched', () => { // **Validates: Requirements 5.1, 5.3** - fc.assert( - fc.property( - seriesNameArb, - globArrayGen, - fc.array(subdirGen, {minLength: 1, maxLength: 4}).filter(arr => new Set(arr).size === arr.length), - (seriName, globs, subdirs) => { - const rule = createMockRulePrompt(seriName, globs) - const subSeries: Record = {} - for (const sd of subdirs) subSeries[sd] = [seriName] - const config: ProjectConfig = {subSeries} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - const resultGlobs = result[0]!.globs - expect(resultGlobs.length).toBeGreaterThanOrEqual(subdirs.length) // at least as many globs as unique matched subdirs - } - ), - {numRuns: 200} - ) - }) -}) diff --git a/packages/plugin-output-shared/src/utils/typeSpecificFilters.property.test.ts b/packages/plugin-output-shared/src/utils/typeSpecificFilters.property.test.ts deleted file mode 100644 index 6bb0ddc5..00000000 --- a/packages/plugin-output-shared/src/utils/typeSpecificFilters.property.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** Property 6: Type-specific filters use correct config sections. Validates: Requirements 7.1, 7.2, 7.3, 7.4 */ -import type {FastCommandPrompt, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' -import type {ProjectConfig} from '@truenine/plugin-shared/types' -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -import {filterCommandsByProjectConfig} from './commandFilter' -import {filterRulesByProjectConfig} from './ruleFilter' -import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' -import {filterSkillsByProjectConfig} from './skillFilter' -import {filterSubAgentsByProjectConfig} from './subAgentFilter' - -const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) - -const seriNameArb: fc.Arbitrary = fc.oneof( - fc.constant(null), - fc.constant(void 0), - seriesNameArb, - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) -) - -const optionalSeriesArb = fc.option(fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), {nil: void 0}) - -const typeSeriesConfigArb = fc.record({includeSeries: optionalSeriesArb}) - -const projectConfigArb: fc.Arbitrary = fc.record({ - includeSeries: optionalSeriesArb, - rules: fc.option(typeSeriesConfigArb, {nil: void 0}), - skills: fc.option(typeSeriesConfigArb, {nil: void 0}), - subAgents: fc.option(typeSeriesConfigArb, {nil: void 0}), - commands: fc.option(typeSeriesConfigArb, {nil: void 0}) -}) - -function makeSkill(seriName: string | string[] | null | undefined): SkillPrompt { - return {seriName} as unknown as SkillPrompt -} - -function makeRule(seriName: string | string[] | null | undefined): RulePrompt { - return {seriName, globs: [], scope: 'project', series: '', ruleName: '', type: 'Rule'} as unknown as RulePrompt -} - -function makeSubAgent(seriName: string | string[] | null | undefined): SubAgentPrompt { - return {seriName, agentName: '', type: 'SubAgent'} as unknown as SubAgentPrompt -} - -function makeCommand(seriName: string | string[] | null | undefined): FastCommandPrompt { - return {seriName, commandName: '', type: 'FastCommand'} as unknown as FastCommandPrompt -} - -describe('property 6: type-specific filters use correct config sections', () => { - it('filterSkillsByProjectConfig matches manual filtering with skills includeSeries', () => { // **Validates: Requirement 7.1** - fc.assert( - fc.property( - projectConfigArb, - fc.array(seriNameArb, {minLength: 0, maxLength: 10}), - (config, seriNames) => { - const skills = seriNames.map(makeSkill) - const filtered = filterSkillsByProjectConfig(skills, config) - const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.skills?.includeSeries) - const expected = skills.filter(s => matchesSeries(s.seriName, effectiveSeries)) - expect(filtered).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) - - it('filterRulesByProjectConfig matches manual filtering with rules includeSeries', () => { // **Validates: Requirement 7.2** - fc.assert( - fc.property( - projectConfigArb, - fc.array(seriNameArb, {minLength: 0, maxLength: 10}), - (config, seriNames) => { - const rules = seriNames.map(makeRule) - const filtered = filterRulesByProjectConfig(rules, config) - const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.rules?.includeSeries) - const expected = rules.filter(r => matchesSeries(r.seriName, effectiveSeries)) - expect(filtered).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) - - it('filterSubAgentsByProjectConfig matches manual filtering with subAgents includeSeries', () => { // **Validates: Requirement 7.3** - fc.assert( - fc.property( - projectConfigArb, - fc.array(seriNameArb, {minLength: 0, maxLength: 10}), - (config, seriNames) => { - const subAgents = seriNames.map(makeSubAgent) - const filtered = filterSubAgentsByProjectConfig(subAgents, config) - const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.subAgents?.includeSeries) - const expected = subAgents.filter(sa => matchesSeries(sa.seriName, effectiveSeries)) - expect(filtered).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) - - it('filterCommandsByProjectConfig matches manual filtering with commands includeSeries', () => { // **Validates: Requirement 7.4** - fc.assert( - fc.property( - projectConfigArb, - fc.array(seriNameArb, {minLength: 0, maxLength: 10}), - (config, seriNames) => { - const commands = seriNames.map(makeCommand) - const filtered = filterCommandsByProjectConfig(commands, config) - const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.commands?.includeSeries) - const expected = commands.filter(c => matchesSeries(c.seriName, effectiveSeries)) - expect(filtered).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) -}) diff --git a/packages/plugin-output-shared/tsconfig.eslint.json b/packages/plugin-output-shared/tsconfig.eslint.json deleted file mode 100644 index 585b38ee..00000000 --- a/packages/plugin-output-shared/tsconfig.eslint.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "skipLibCheck": true - }, - "include": [ - "src/**/*.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "env.d.ts", - "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" - ], - "exclude": [ - "../node_modules", - "dist", - "coverage" - ] -} diff --git a/packages/plugin-output-shared/tsconfig.json b/packages/plugin-output-shared/tsconfig.json deleted file mode 100644 index 03cd50a3..00000000 --- a/packages/plugin-output-shared/tsconfig.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": [ - "ESNext" - ], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { - "@/*": [ - "./src/*" - ] - }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": [ - "src/**/*", - "env.d.ts", - "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" - ], - "exclude": [ - "../node_modules", - "dist" - ] -} diff --git a/packages/plugin-output-shared/tsconfig.lib.json b/packages/plugin-output-shared/tsconfig.lib.json deleted file mode 100644 index b2449b37..00000000 --- a/packages/plugin-output-shared/tsconfig.lib.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "noEmit": false, - "outDir": "../dist", - "skipLibCheck": true - }, - "include": [ - "src/**/*", - "env.d.ts" - ], - "exclude": [ - "../node_modules", - "dist", - "**/*.spec.ts", - "**/*.test.ts" - ] -} diff --git a/packages/plugin-output-shared/tsconfig.test.json b/packages/plugin-output-shared/tsconfig.test.json deleted file mode 100644 index 65c3c9ad..00000000 --- a/packages/plugin-output-shared/tsconfig.test.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "lib": [ - "ESNext", - "DOM" - ], - "types": [ - "vitest/globals", - "node" - ] - }, - "include": [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "vitest.config.ts", - "vite.config.ts", - "env.d.ts" - ], - "exclude": [ - "../node_modules", - "dist" - ] -} diff --git a/packages/plugin-output-shared/tsdown.config.ts b/packages/plugin-output-shared/tsdown.config.ts deleted file mode 100644 index 066c0701..00000000 --- a/packages/plugin-output-shared/tsdown.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: [ - './src/index.ts', - './src/utils/index.ts', - './src/registry/index.ts', - '!**/*.{spec,test}.*' - ], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: { - '@': resolve('src') - }, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-output-shared/vite.config.ts b/packages/plugin-output-shared/vite.config.ts deleted file mode 100644 index 2dcc5646..00000000 --- a/packages/plugin-output-shared/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - } - } -}) diff --git a/packages/plugin-output-shared/vitest.config.ts b/packages/plugin-output-shared/vitest.config.ts deleted file mode 100644 index a06eb3a7..00000000 --- a/packages/plugin-output-shared/vitest.config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {fileURLToPath} from 'node:url' - -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' - -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: { - enabled: true, - tsconfig: './tsconfig.test.json' - }, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/', - 'dist/', - '**/*.test.ts', - '**/*.property.test.ts' - ] - } - } - }) -) diff --git a/packages/plugin-qoder-ide/eslint.config.ts b/packages/plugin-qoder-ide/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-qoder-ide/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-qoder-ide/package.json b/packages/plugin-qoder-ide/package.json deleted file mode 100644 index a9a53f20..00000000 --- a/packages/plugin-qoder-ide/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@truenine/plugin-qoder-ide", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Qoder IDE output plugin", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "picomatch": "catalog:" - } -} diff --git a/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.frontmatter.test.ts b/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.frontmatter.test.ts deleted file mode 100644 index 06b4ed2e..00000000 --- a/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.frontmatter.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import {beforeEach, describe, expect, it} from 'vitest' -import {QoderIDEPluginOutputPlugin} from './QoderIDEPluginOutputPlugin' - -describe('qoderidepluginoutputplugin front matter', () => { - let plugin: QoderIDEPluginOutputPlugin - - beforeEach(() => plugin = new QoderIDEPluginOutputPlugin()) - - describe('buildAlwaysRuleContent', () => { - it('should include type: user_command in front matter', () => { - const content = 'Test always rule content' - const result = (plugin as any).buildAlwaysRuleContent(content) - - expect(result).toContain('type: user_command') - expect(result).toContain('trigger: always_on') - expect(result).toContain(content) - }) - }) - - describe('buildGlobRuleContent', () => { - it('should include type: user_command in front matter', () => { - const mockChild = { - content: 'Test glob rule content', - workingChildDirectoryPath: {path: 'src/utils'} - } - - const result = (plugin as any).buildGlobRuleContent(mockChild) - - expect(result).toContain('type: user_command') - expect(result).toContain('trigger: glob') - expect(result).toContain('glob: src/utils/**') - expect(result).toContain('Test glob rule content') - }) - }) - - describe('buildFastCommandFrontMatter', () => { - it('should include type: user_command in fast command front matter', () => { - const mockCmd = { - yamlFrontMatter: { - description: 'Test fast command', - argumentHint: 'test args', - allowTools: ['tool1', 'tool2'] - } - } - - const result = (plugin as any).buildFastCommandFrontMatter(mockCmd) - - expect(result.type).toBe('user_command') - expect(result.description).toBe('Test fast command') - expect(result.argumentHint).toBe('test args') - expect(result.allowTools).toEqual(['tool1', 'tool2']) - }) - - it('should handle fast command without yamlFrontMatter', () => { - const mockCmd = {} - - const result = (plugin as any).buildFastCommandFrontMatter(mockCmd) - - expect(result.type).toBe('user_command') - expect(result.description).toBe('Fast command') - }) - }) -}) diff --git a/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.projectConfig.test.ts b/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.projectConfig.test.ts deleted file mode 100644 index cef861dc..00000000 --- a/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.projectConfig.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type {OutputWriteContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {QoderIDEPluginOutputPlugin} from './QoderIDEPluginOutputPlugin' - -class TestableQoderIDEPlugin extends QoderIDEPluginOutputPlugin { - private mockHomeDir: string | null = null - public setMockHomeDir(dir: string | null): void { this.mockHomeDir = dir } - protected override getHomeDir(): string { return this.mockHomeDir ?? super.getHomeDir() } -} - -function createMockWriteContext(tempDir: string, rules: unknown[], projects: unknown[]): OutputWriteContext { - return { - collectedInputContext: { - workspace: { - projects: projects as never, - directory: {pathKind: 1, path: tempDir, basePath: tempDir, getDirectoryName: () => 'workspace', getAbsolutePath: () => tempDir} - }, - ideConfigFiles: [], - rules: rules as never, - fastCommands: [], - skills: [], - globalMemory: void 0, - aiAgentIgnoreConfigFiles: [] - }, - dryRun: false, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as never, - fs, - path, - glob: vi.fn() as never - } -} - -describe('qoderIDEPluginOutputPlugin - projectConfig filtering', () => { - let tempDir: string, plugin: TestableQoderIDEPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-proj-config-test-')) - plugin = new TestableQoderIDEPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch {} - }) - - function ruleFile(projectPath: string, series: string, ruleName: string): string { - return path.join(tempDir, projectPath, '.qoder', 'rules', `rule-${series}-${ruleName}.md`) - } - - describe('writeProjectOutputs', () => { - it('should write all project rules when no projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [createMockProject('proj1', tempDir, 'proj1')])) - - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(true) - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(true) - }) - - it('should only write rules matching include filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ])) - - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(true) - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(false) - }) - - it('should not write rules not matching includeSeries filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) - ])) - - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(false) - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(true) - }) - - it('should write rules without seriName regardless of include filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', void 0, 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ])) - - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(true) - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(false) - }) - - it('should write expanded glob when subSeries matches seriName', async () => { - const rules = [createMockRulePrompt('test', 'rule1', 'uniapp', 'project')] - await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ - createMockProject('proj1', tempDir, 'proj1', {rules: {subSeries: {applet: ['uniapp']}}}) - ])) - - const content = fs.readFileSync(ruleFile('proj1', 'test', 'rule1'), 'utf8') - expect(content).toContain('applet/') - }) - }) -}) diff --git a/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.test.ts b/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.test.ts deleted file mode 100644 index 511e9751..00000000 --- a/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -import type { - CollectedInputContext, - FastCommandPrompt, - GlobalMemoryPrompt, - OutputPluginContext, - OutputWriteContext, - ProjectChildrenMemoryPrompt, - ProjectRootMemoryPrompt, - RelativePath, - SkillPrompt -} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {QoderIDEPluginOutputPlugin} from './QoderIDEPluginOutputPlugin' - -vi.mock('node:fs') - -const MOCK_WORKSPACE_DIR = '/workspace/test' - -class TestableQoderIDEPluginOutputPlugin extends QoderIDEPluginOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => path.basename(pathStr), - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -function createMockRootMemoryPrompt(content: string, basePath: string): ProjectRootMemoryPrompt { - return { - type: PromptKind.ProjectRootMemory, - content, - dir: createMockRelativePath('.', basePath), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative - } as ProjectRootMemoryPrompt -} - -function createMockChildMemoryPrompt( - content: string, - projectPath: string, - basePath: string, - workingPath?: string -): ProjectChildrenMemoryPrompt { - const childPath = workingPath ?? projectPath - return { - type: PromptKind.ProjectChildrenMemory, - dir: createMockRelativePath(projectPath, basePath), - workingChildDirectoryPath: createMockRelativePath(childPath, basePath), - content, - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative - } as ProjectChildrenMemoryPrompt -} - -function createMockGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt { - return { - type: PromptKind.GlobalMemory, - content, - dir: createMockRelativePath('.', basePath), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative, - parentDirectoryPath: { - type: 'UserHome', - directory: createMockRelativePath('.qoder', basePath) - } - } as GlobalMemoryPrompt -} - -function createMockFastCommandPrompt( - commandName: string, - series?: string -): FastCommandPrompt { - const content = 'Run something' - return { - type: PromptKind.FastCommand, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - markdownContents: [], - yamlFrontMatter: { - description: 'Fast command' - }, - ...series != null && {series}, - commandName - } as FastCommandPrompt -} - -function createMockSkillPrompt( - name: string, - description: string, - content: string, - options?: { - mcpConfig?: {rawContent: string, mcpServers: Record} - childDocs?: {relativePath: string, content: string}[] - resources?: {relativePath: string, content: string, encoding: 'text' | 'base64'}[] - } -): SkillPrompt { - return { - type: PromptKind.Skill, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath(name, MOCK_WORKSPACE_DIR), - markdownContents: [], - yamlFrontMatter: { - name, - description - }, - mcpConfig: options?.mcpConfig != null - ? { - type: PromptKind.SkillMcpConfig, - rawContent: options.mcpConfig.rawContent, - mcpServers: options.mcpConfig.mcpServers - } - : void 0, - childDocs: options?.childDocs, - resources: options?.resources - } as SkillPrompt -} - -function createMockOutputPluginContext( - collectedInputContext: Partial -): OutputPluginContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [] - }, - ideConfigFiles: [], - ...collectedInputContext - } as CollectedInputContext - } -} - -function createMockOutputWriteContext( - collectedInputContext: Partial, - dryRun = false -): OutputWriteContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [] - }, - ideConfigFiles: [], - ...collectedInputContext - } as CollectedInputContext, - dryRun - } -} - -describe('qoder IDE plugin output plugin', () => { - let plugin: TestableQoderIDEPluginOutputPlugin - - beforeEach(() => { - plugin = new TestableQoderIDEPluginOutputPlugin() - plugin.setMockHomeDir('/home/test') - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.mkdirSync).mockReturnValue(void 0) - vi.mocked(fs.writeFileSync).mockReturnValue(void 0) - }) - - afterEach(() => vi.clearAllMocks()) - - describe('registerProjectOutputDirs', () => { - it('should register .qoder/rules for each project', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [ - {dirFromWorkspacePath: createMockRelativePath('project-a', MOCK_WORKSPACE_DIR)}, - {dirFromWorkspacePath: createMockRelativePath('project-b', MOCK_WORKSPACE_DIR)} - ] - } - }) - - const results = await plugin.registerProjectOutputDirs(ctx) - - expect(results).toHaveLength(2) - expect(results[0].path).toBe(path.join('project-a', '.qoder', 'rules')) - expect(results[1].path).toBe(path.join('project-b', '.qoder', 'rules')) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should register global.md, always.md, and child glob rules', async () => { - const projectDir = createMockRelativePath('project-a', MOCK_WORKSPACE_DIR) - const ctx = createMockOutputPluginContext({ - globalMemory: createMockGlobalMemoryPrompt('Global rules', MOCK_WORKSPACE_DIR), - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [ - { - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('Root rules', MOCK_WORKSPACE_DIR), - childMemoryPrompts: [ - createMockChildMemoryPrompt('Child rules', 'project-a/src', MOCK_WORKSPACE_DIR, 'src') - ] - } - ] - } - }) - - const results = await plugin.registerProjectOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('project-a', '.qoder', 'rules', 'global.md')) - expect(paths).toContain(path.join('project-a', '.qoder', 'rules', 'always.md')) - expect(paths).toContain(path.join('project-a', '.qoder', 'rules', 'glob-src.md')) - }) - }) - - describe('registerGlobalOutputDirs', () => { - it('should return empty when no fast commands exist', async () => { - const ctx = createMockOutputPluginContext({}) - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(0) - }) - - it('should register ~/.qoder/commands when fast commands exist', async () => { - const ctx = createMockOutputPluginContext({ - fastCommands: [createMockFastCommandPrompt('compile')] - }) - - const results = await plugin.registerGlobalOutputDirs(ctx) - - expect(results).toHaveLength(1) - expect(results[0].basePath).toBe(path.join('/home/test', '.qoder')) - expect(results[0].path).toBe('commands') - }) - - it('should register ~/.qoder/skills/ for each skill', async () => { - const ctx = createMockOutputPluginContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content'), - createMockSkillPrompt('another-skill', 'Another skill', 'More content') - ] - }) - - const results = await plugin.registerGlobalOutputDirs(ctx) - - expect(results).toHaveLength(2) - expect(results[0].path).toBe(path.join('skills', 'my-skill')) - expect(results[1].path).toBe(path.join('skills', 'another-skill')) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register fast command files under ~/.qoder/commands', async () => { - const ctx = createMockOutputPluginContext({ - fastCommands: [ - createMockFastCommandPrompt('compile', 'build'), - createMockFastCommandPrompt('test') - ] - }) - - const results = await plugin.registerGlobalOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('commands', 'build-compile.md')) - expect(paths).toContain(path.join('commands', 'test.md')) - }) - - it('should register skill files under ~/.qoder/skills', async () => { - const ctx = createMockOutputPluginContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content') - ] - }) - - const results = await plugin.registerGlobalOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('skills', 'my-skill', 'SKILL.md')) - }) - - it('should register mcp.json when skill has MCP config', async () => { - const ctx = createMockOutputPluginContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content', { - mcpConfig: { - rawContent: '{"mcpServers": {}}', - mcpServers: {} - } - }) - ] - }) - - const results = await plugin.registerGlobalOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('skills', 'my-skill', 'SKILL.md')) - expect(paths).toContain(path.join('skills', 'my-skill', 'mcp.json')) - }) - - it('should register child docs and resources', async () => { - const ctx = createMockOutputPluginContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content', { - childDocs: [{relativePath: 'docs/guide.mdx', content: 'Guide content'}], - resources: [{relativePath: 'assets/image.png', content: 'base64data', encoding: 'base64'}] - }) - ] - }) - - const results = await plugin.registerGlobalOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('skills', 'my-skill', 'SKILL.md')) - expect(paths).toContain(path.join('skills', 'my-skill', 'docs', 'guide.md')) - expect(paths).toContain(path.join('skills', 'my-skill', 'assets', 'image.png')) - }) - }) - - describe('canWrite', () => { - it('should return true when project prompts exist', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [ - { - dirFromWorkspacePath: createMockRelativePath('project-a', MOCK_WORKSPACE_DIR), - rootMemoryPrompt: createMockRootMemoryPrompt('Root rules', MOCK_WORKSPACE_DIR) - } - ] - } - }) - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return true when skills exist', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [] - }, - skills: [createMockSkillPrompt('my-skill', 'A test skill', 'Skill content')] - }) - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return false when nothing to write', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [] - } - }) - - const result = await plugin.canWrite(ctx) - expect(result).toBe(false) - }) - }) - - describe('writeProjectOutputs', () => { - it('should write global, root, and child rule files with front matter', async () => { - const projectDir = createMockRelativePath('project-a', MOCK_WORKSPACE_DIR) - const ctx = createMockOutputWriteContext({ - globalMemory: createMockGlobalMemoryPrompt('Global rules', MOCK_WORKSPACE_DIR), - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [ - { - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('Root rules', MOCK_WORKSPACE_DIR), - childMemoryPrompts: [ - createMockChildMemoryPrompt('Child rules', 'project-a/src', MOCK_WORKSPACE_DIR, 'src') - ] - } - ] - } - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(3) - - const [calls] = [vi.mocked(fs.writeFileSync).mock.calls] - expect(calls).toHaveLength(3) - - const globalCall = calls.find(call => String(call[0]).includes(path.join('project-a', '.qoder', 'rules', 'global.md'))) - const rootCall = calls.find(call => String(call[0]).includes(path.join('project-a', '.qoder', 'rules', 'always.md'))) - const childCall = calls.find(call => String(call[0]).includes(path.join('project-a', '.qoder', 'rules', 'glob-src.md'))) - - expect(globalCall).toBeDefined() - expect(rootCall).toBeDefined() - expect(childCall).toBeDefined() - - expect(String(globalCall?.[1])).toContain('trigger: always_on') - expect(String(globalCall?.[1])).toContain('Global rules') - - expect(String(rootCall?.[1])).toContain('trigger: always_on') - expect(String(rootCall?.[1])).toContain('Root rules') - - expect(String(childCall?.[1])).toContain('trigger: glob') - expect(String(childCall?.[1])).toContain('glob: src/**') - expect(String(childCall?.[1])).toContain('Child rules') - }) - }) - - describe('writeGlobalOutputs', () => { - it('should write fast command files with front matter', async () => { - const ctx = createMockOutputWriteContext({ - fastCommands: [ - createMockFastCommandPrompt('compile', 'build') - ] - }) - - const results = await plugin.writeGlobalOutputs(ctx) - - expect(results.files).toHaveLength(1) - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] - expect(String(writeCall?.[0])).toContain(path.join('.qoder', 'commands', 'build-compile.md')) - expect(String(writeCall?.[1])).toContain('description: Fast command') - expect(String(writeCall?.[1])).toContain('Run something') - }) - - it('should write skill files to ~/.qoder/skills/', async () => { - const ctx = createMockOutputWriteContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content') - ] - }) - - const results = await plugin.writeGlobalOutputs(ctx) - - expect(results.files).toHaveLength(1) - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] - expect(String(writeCall?.[0])).toContain(path.join('.qoder', 'skills', 'my-skill', 'SKILL.md')) - expect(String(writeCall?.[1])).toContain('name: my-skill') - expect(String(writeCall?.[1])).toContain('description: A test skill') - expect(String(writeCall?.[1])).toContain('Skill content') - }) - - it('should write mcp.json when skill has MCP config', async () => { - const ctx = createMockOutputWriteContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content', { - mcpConfig: { - rawContent: '{"mcpServers": {"test-server": {}}}', - mcpServers: {'test-server': {}} - } - }) - ] - }) - - const results = await plugin.writeGlobalOutputs(ctx) - - expect(results.files).toHaveLength(2) - - const writeCalls = vi.mocked(fs.writeFileSync).mock.calls - const mcpCall = writeCalls.find(call => String(call[0]).includes('mcp.json')) - expect(mcpCall).toBeDefined() - expect(String(mcpCall?.[1])).toContain('mcpServers') - }) - }) -}) diff --git a/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.ts b/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.ts deleted file mode 100644 index eb3ef122..00000000 --- a/packages/plugin-qoder-ide/src/QoderIDEPluginOutputPlugin.ts +++ /dev/null @@ -1,426 +0,0 @@ -import type { - FastCommandPrompt, - OutputPluginContext, - OutputWriteContext, - ProjectChildrenMemoryPrompt, - RulePrompt, - SkillPrompt, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import {Buffer} from 'node:buffer' -import * as path from 'node:path' -import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' - -const QODER_CONFIG_DIR = '.qoder' -const RULES_SUBDIR = 'rules' -const COMMANDS_SUBDIR = 'commands' -const SKILLS_SUBDIR = 'skills' -const GLOBAL_RULE_FILE = 'global.md' -const PROJECT_RULE_FILE = 'always.md' -const CHILD_RULE_FILE_PREFIX = 'glob-' -const SKILL_FILE_NAME = 'SKILL.md' -const MCP_CONFIG_FILE = 'mcp.json' -const TRIGGER_ALWAYS = 'always_on' -const TRIGGER_GLOB = 'glob' -const RULE_GLOB_KEY = 'glob' -const RULE_FILE_PREFIX = 'rule-' - -export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('QoderIDEPluginOutputPlugin', {globalConfigDir: QODER_CONFIG_DIR, indexignore: '.qoderignore'}) - } - - async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { - const {projects} = ctx.collectedInputContext.workspace - return projects - .filter(p => p.dirFromWorkspacePath != null) - .map(p => this.createProjectRulesDirPath(p.dirFromWorkspacePath!)) - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {workspace, rules} = ctx.collectedInputContext - const {projects} = workspace - const {globalMemory} = ctx.collectedInputContext - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - - if (globalMemory != null) results.push(this.createProjectRuleFilePath(projectDir, GLOBAL_RULE_FILE)) - - if (project.rootMemoryPrompt != null) results.push(this.createProjectRuleFilePath(projectDir, PROJECT_RULE_FILE)) - - if (project.childMemoryPrompts != null) { - for (const child of project.childMemoryPrompts) results.push(this.createProjectRuleFilePath(projectDir, this.buildChildRuleFileName(child))) - } - - if (rules != null && rules.length > 0) { // Handle project rules - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => this.normalizeRuleScope(r) === 'project'), - project.projectConfig - ), - project.projectConfig - ) - for (const rule of projectRules) { - const fileName = this.buildRuleFileName(rule) - results.push(this.createProjectRuleFilePath(projectDir, fileName)) - } - } - } - results.push(...this.registerProjectIgnoreOutputFiles(projects)) - return results - } - - async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { - const globalDir = this.getGlobalConfigDir() - const {fastCommands, skills, rules} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const results: RelativePath[] = [] - - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - if (filteredCommands.length > 0) results.push(this.createRelativePath(COMMANDS_SUBDIR, globalDir, () => COMMANDS_SUBDIR)) - } - - if (skills != null && skills.length > 0) { - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter.name - results.push(this.createRelativePath( - path.join(SKILLS_SUBDIR, skillName), - globalDir, - () => skillName - )) - } - } - - const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global') - if (globalRules != null && globalRules.length > 0) { - results.push(this.createRelativePath( - path.join(RULES_SUBDIR), - globalDir, - () => RULES_SUBDIR - )) - } - return results - } - - async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const globalDir = this.getGlobalConfigDir() - const {fastCommands, skills, rules} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const results: RelativePath[] = [] - const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) - - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) { - const fileName = this.transformFastCommandName(cmd, transformOptions) - results.push(this.createRelativePath( - path.join(COMMANDS_SUBDIR, fileName), - globalDir, - () => COMMANDS_SUBDIR - )) - } - } - - const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global') - if (globalRules != null && globalRules.length > 0) { - for (const rule of globalRules) { - const fileName = this.buildRuleFileName(rule) - results.push(this.createRelativePath( - path.join(RULES_SUBDIR, fileName), - globalDir, - () => RULES_SUBDIR - )) - } - } - - const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] - if (filteredSkills.length > 0) { - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter.name - results.push(this.createRelativePath( - path.join(SKILLS_SUBDIR, skillName, SKILL_FILE_NAME), - globalDir, - () => skillName - )) - - if (skill.mcpConfig != null) { - results.push(this.createRelativePath( - path.join(SKILLS_SUBDIR, skillName, MCP_CONFIG_FILE), - globalDir, - () => skillName - )) - } - - if (skill.childDocs != null) { - for (const childDoc of skill.childDocs) { - results.push(this.createRelativePath( - path.join(SKILLS_SUBDIR, skillName, childDoc.relativePath.replace(/\.mdx$/, '.md')), - globalDir, - () => skillName - )) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) { - results.push(this.createRelativePath( - path.join(SKILLS_SUBDIR, skillName, resource.relativePath), - globalDir, - () => skillName - )) - } - } - } - } - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, globalMemory, fastCommands, skills, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext - const hasProjectPrompts = workspace.projects.some( - p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 - ) - const hasRules = (rules?.length ?? 0) > 0 - const hasQoderIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.qoderignore') ?? false - if (hasProjectPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0 || hasRules || hasQoderIgnore) return true - this.log.trace({action: 'skip', reason: 'noOutputs'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const {workspace, globalMemory, rules} = ctx.collectedInputContext - const {projects} = workspace - const fileResults: WriteResult[] = [] - - for (const project of projects) { - if (project.dirFromWorkspacePath == null) continue - const projectDir = project.dirFromWorkspacePath - - if (globalMemory != null) { - const content = this.buildAlwaysRuleContent(globalMemory.content as string) - fileResults.push(await this.writeProjectRuleFile(ctx, projectDir, GLOBAL_RULE_FILE, content, 'globalRule')) - } - - if (project.rootMemoryPrompt != null) { - const content = this.buildAlwaysRuleContent(project.rootMemoryPrompt.content as string) - fileResults.push(await this.writeProjectRuleFile(ctx, projectDir, PROJECT_RULE_FILE, content, 'projectRootRule')) - } - - if (project.childMemoryPrompts != null) { - for (const child of project.childMemoryPrompts) { - const fileName = this.buildChildRuleFileName(child) - const content = this.buildGlobRuleContent(child) - fileResults.push(await this.writeProjectRuleFile(ctx, projectDir, fileName, content, 'projectChildRule')) - } - } - - if (rules != null && rules.length > 0) { // Write project rules - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => this.normalizeRuleScope(r) === 'project'), - project.projectConfig - ), - project.projectConfig - ) - for (const rule of projectRules) { - const fileName = this.buildRuleFileName(rule) - const content = this.buildRuleContent(rule) - fileResults.push(await this.writeProjectRuleFile(ctx, projectDir, fileName, content, 'projectRule')) - } - } - } - const ignoreResults = await this.writeProjectIgnoreFiles(ctx) - fileResults.push(...ignoreResults) - return {files: fileResults, dirs: []} - } - - async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {fastCommands, skills, rules} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const fileResults: WriteResult[] = [] - const globalDir = this.getGlobalConfigDir() - const commandsDir = path.join(globalDir, COMMANDS_SUBDIR) - const skillsDir = path.join(globalDir, SKILLS_SUBDIR) - const rulesDir = path.join(globalDir, RULES_SUBDIR) - - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalFastCommand(ctx, commandsDir, cmd)) - } - - if (rules != null && rules.length > 0) { - const globalRules = rules.filter(r => this.normalizeRuleScope(r) === 'global') - for (const rule of globalRules) fileResults.push(await this.writeRuleFile(ctx, rulesDir, rule)) - } - - if (skills == null || skills.length === 0) return {files: fileResults, dirs: []} - - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - for (const skill of filteredSkills) fileResults.push(...await this.writeGlobalSkill(ctx, skillsDir, skill)) - return {files: fileResults, dirs: []} - } - - private createProjectRulesDirPath(projectDir: RelativePath): RelativePath { - return this.createRelativePath( - path.join(projectDir.path, QODER_CONFIG_DIR, RULES_SUBDIR), - projectDir.basePath, - () => RULES_SUBDIR - ) - } - - private createProjectRuleFilePath(projectDir: RelativePath, fileName: string): RelativePath { - return this.createRelativePath( - path.join(projectDir.path, QODER_CONFIG_DIR, RULES_SUBDIR, fileName), - projectDir.basePath, - () => RULES_SUBDIR - ) - } - - private buildChildRuleFileName(child: ProjectChildrenMemoryPrompt): string { - const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path - const normalized = childPath.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '').replaceAll('/', '-') - return `${CHILD_RULE_FILE_PREFIX}${normalized.length > 0 ? normalized : 'root'}.md` - } - - private buildAlwaysRuleContent(content: string): string { - return buildMarkdownWithFrontMatter({trigger: TRIGGER_ALWAYS, type: 'user_command'}, content) - } - - private buildGlobRuleContent(child: ProjectChildrenMemoryPrompt): string { - const childPath = child.workingChildDirectoryPath?.path ?? child.dir.path - const normalized = childPath.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '') - const pattern = normalized.length === 0 ? '**/*' : `${normalized}/**` - return buildMarkdownWithFrontMatter({trigger: TRIGGER_GLOB, [RULE_GLOB_KEY]: pattern, type: 'user_command'}, child.content as string) - } - - private async writeProjectRuleFile( - ctx: OutputWriteContext, - projectDir: RelativePath, - fileName: string, - content: string, - label: string - ): Promise { - const rulesDir = path.join(projectDir.basePath, projectDir.path, QODER_CONFIG_DIR, RULES_SUBDIR) - const fullPath = path.join(rulesDir, fileName) - return this.writeFile(ctx, fullPath, content, label) - } - - private async writeGlobalFastCommand( - ctx: OutputWriteContext, - commandsDir: string, - cmd: FastCommandPrompt - ): Promise { - const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) - const fileName = this.transformFastCommandName(cmd, transformOptions) - const fullPath = path.join(commandsDir, fileName) - const fmData = this.buildFastCommandFrontMatter(cmd) - const content = buildMarkdownWithFrontMatter(fmData, cmd.content) - return this.writeFile(ctx, fullPath, content, 'globalFastCommand') - } - - private async writeRuleFile( - ctx: OutputWriteContext, - rulesDir: string, - rule: RulePrompt - ): Promise { - const fileName = this.buildRuleFileName(rule) - const fullPath = path.join(rulesDir, fileName) - const content = this.buildRuleContent(rule) - return this.writeFile(ctx, fullPath, content, 'rule') - } - - private async writeGlobalSkill( - ctx: OutputWriteContext, - skillsDir: string, - skill: SkillPrompt - ): Promise { - const results: WriteResult[] = [] - const skillName = skill.yamlFrontMatter.name - const skillDir = path.join(skillsDir, skillName) - const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) - - const fmData = this.buildSkillFrontMatter(skill) - const content = buildMarkdownWithFrontMatter(fmData, skill.content as string) - results.push(await this.writeFile(ctx, skillFilePath, content, 'skill')) - - if (skill.mcpConfig != null) { - const mcpPath = path.join(skillDir, MCP_CONFIG_FILE) - results.push(await this.writeFile(ctx, mcpPath, skill.mcpConfig.rawContent, 'mcpConfig')) - } - - if (skill.childDocs != null) { - for (const childDoc of skill.childDocs) { - const childPath = path.join(skillDir, childDoc.relativePath.replace(/\.mdx$/, '.md')) - results.push(await this.writeFile(ctx, childPath, childDoc.content as string, 'childDoc')) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) { - const resourcePath = path.join(skillDir, resource.relativePath) - if (resource.encoding === 'base64') { - const buffer = Buffer.from(resource.content, 'base64') - const dir = path.dirname(resourcePath) - this.ensureDirectory(dir) - this.writeFileSyncBuffer(resourcePath, buffer) - results.push({ - path: this.createRelativePath(resource.relativePath, skillDir, () => skillName), - success: true - }) - } else results.push(await this.writeFile(ctx, resourcePath, resource.content, 'resource')) - } - } - return results - } - - private buildSkillFrontMatter(skill: SkillPrompt): Record { - const fm = skill.yamlFrontMatter - return { - name: fm.name, - description: fm.description, - type: 'user_command', - ...fm.displayName != null && {displayName: fm.displayName}, - ...fm.keywords != null && fm.keywords.length > 0 && {keywords: fm.keywords}, - ...fm.author != null && {author: fm.author}, - ...fm.version != null && {version: fm.version}, - ...fm.allowTools != null && fm.allowTools.length > 0 && {allowTools: fm.allowTools} - } - } - - private buildFastCommandFrontMatter(cmd: FastCommandPrompt): Record { - const fm = cmd.yamlFrontMatter - if (fm == null) return {description: 'Fast command', type: 'user_command'} - return { - description: fm.description, - type: 'user_command', - ...fm.argumentHint != null && {argumentHint: fm.argumentHint}, - ...fm.allowTools != null && fm.allowTools.length > 0 && {allowTools: fm.allowTools} - } - } - - private buildRuleFileName(rule: RulePrompt): string { - return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` - } - - private buildRuleContent(rule: RulePrompt): string { - const fmData: Record = { - trigger: TRIGGER_GLOB, - [RULE_GLOB_KEY]: rule.globs.length > 0 ? rule.globs.join(', ') : '**/*', - type: 'user_command' - } - return buildMarkdownWithFrontMatter(fmData, rule.content) - } - - protected override normalizeRuleScope(rule: RulePrompt): 'global' | 'project' { - return rule.scope || 'global' - } -} diff --git a/packages/plugin-qoder-ide/src/index.ts b/packages/plugin-qoder-ide/src/index.ts deleted file mode 100644 index 4573a43c..00000000 --- a/packages/plugin-qoder-ide/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - QoderIDEPluginOutputPlugin -} from './QoderIDEPluginOutputPlugin' diff --git a/packages/plugin-qoder-ide/tsconfig.eslint.json b/packages/plugin-qoder-ide/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-qoder-ide/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-qoder-ide/tsconfig.json b/packages/plugin-qoder-ide/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-qoder-ide/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-qoder-ide/tsconfig.lib.json b/packages/plugin-qoder-ide/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-qoder-ide/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-qoder-ide/tsconfig.test.json b/packages/plugin-qoder-ide/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-qoder-ide/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-qoder-ide/tsdown.config.ts b/packages/plugin-qoder-ide/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-qoder-ide/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-qoder-ide/vite.config.ts b/packages/plugin-qoder-ide/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-qoder-ide/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-qoder-ide/vitest.config.ts b/packages/plugin-qoder-ide/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-qoder-ide/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-readme/eslint.config.ts b/packages/plugin-readme/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-readme/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-readme/package.json b/packages/plugin-readme/package.json deleted file mode 100644 index 7bbc6541..00000000 --- a/packages/plugin-readme/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-readme", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "README.md output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-readme/src/ReadmeMdConfigFileOutputPlugin.property.test.ts b/packages/plugin-readme/src/ReadmeMdConfigFileOutputPlugin.property.test.ts deleted file mode 100644 index ed73f513..00000000 --- a/packages/plugin-readme/src/ReadmeMdConfigFileOutputPlugin.property.test.ts +++ /dev/null @@ -1,499 +0,0 @@ -import type { - CollectedInputContext, - OutputPluginContext, - OutputWriteContext, - ReadmeFileKind, - ReadmePrompt, - RelativePath, - Workspace -} from '@truenine/plugin-shared' - -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {createLogger, FilePathKind, PromptKind, README_FILE_KIND_MAP} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {afterEach, beforeEach, describe, expect, it} from 'vitest' -import {ReadmeMdConfigFileOutputPlugin} from './ReadmeMdConfigFileOutputPlugin' - -/** - * Feature: readme-md-plugin - * Property-based tests for ReadmeMdConfigFileOutputPlugin - */ -describe('readmeMdConfigFileOutputPlugin property tests', () => { - const plugin = new ReadmeMdConfigFileOutputPlugin() - let tempDir: string - - const allFileKinds = Object.keys(README_FILE_KIND_MAP) as ReadmeFileKind[] - - beforeEach(() => tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'readme-output-test-'))) - - afterEach(() => fs.rmSync(tempDir, {recursive: true, force: true})) - - function createReadmePrompt( - projectName: string, - content: string, - isRoot: boolean, - basePath: string, - subdir?: string, - fileKind: ReadmeFileKind = 'Readme' - ): ReadmePrompt { - const targetPath = isRoot ? projectName : path.join(projectName, subdir ?? '') - - const targetDir: RelativePath = { - pathKind: FilePathKind.Relative, - path: targetPath, - basePath, - getDirectoryName: () => isRoot ? projectName : path.basename(subdir ?? ''), - getAbsolutePath: () => path.resolve(basePath, targetPath) - } - - const dir: RelativePath = { - pathKind: FilePathKind.Relative, - path: targetPath, - basePath, - getDirectoryName: () => isRoot ? projectName : path.basename(subdir ?? ''), - getAbsolutePath: () => path.resolve(basePath, targetPath) - } - - return { - type: PromptKind.Readme, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - projectName, - targetDir, - isRoot, - fileKind, - markdownContents: [], - dir - } - } - - function createMockPluginContext( - readmePrompts: readonly ReadmePrompt[], - basePath: string - ): OutputPluginContext { - const workspace: Workspace = { - directory: { - pathKind: FilePathKind.Absolute, - path: basePath, - getDirectoryName: () => path.basename(basePath), - getAbsolutePath: () => basePath - }, - projects: [] - } - - const collectedInputContext: CollectedInputContext = { - workspace, - ideConfigFiles: [], - readmePrompts - } - - return { - collectedInputContext, - logger: createLogger('test', 'error'), - fs, - path, - glob: {} as typeof import('fast-glob') - } - } - - function createMockWriteContext( - readmePrompts: readonly ReadmePrompt[], - basePath: string, - dryRun: boolean = false - ): OutputWriteContext { - const pluginCtx = createMockPluginContext(readmePrompts, basePath) - return { - ...pluginCtx, - dryRun - } - } - - describe('property 3: Output Path Mapping', () => { - const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) - .filter(s => s.trim().length > 0) - - const fileKindArb = fc.constantFrom(...allFileKinds) - - it('should register correct output paths for root READMEs', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readme = createReadmePrompt(projectName, content, true, tempDir) - const ctx = createMockPluginContext([readme], tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths.length).toBe(1) - expect(registeredPaths[0].path).toBe(path.join(projectName, 'README.md')) - expect(registeredPaths[0].basePath).toBe(tempDir) - expect(registeredPaths[0].getAbsolutePath()).toBe( - path.join(tempDir, projectName, 'README.md') - ) - } - ), - {numRuns: 100} - ) - }) - - it('should register correct output paths for child READMEs', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - subdirNameArb, - readmeContentArb, - async (projectName, subdir, content) => { - const readme = createReadmePrompt(projectName, content, false, tempDir, subdir) - const ctx = createMockPluginContext([readme], tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths.length).toBe(1) - expect(registeredPaths[0].path).toBe(path.join(projectName, subdir, 'README.md')) - expect(registeredPaths[0].basePath).toBe(tempDir) - expect(registeredPaths[0].getAbsolutePath()).toBe( - path.join(tempDir, projectName, subdir, 'README.md') - ) - } - ), - {numRuns: 100} - ) - }) - - it('should write root README to correct path', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readme = createReadmePrompt(projectName, content, true, tempDir) - const ctx = createMockWriteContext([readme], tempDir, false) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files.length).toBe(1) - expect(results.files[0].success).toBe(true) - - const expectedPath = path.join(tempDir, projectName, 'README.md') - expect(fs.existsSync(expectedPath)).toBe(true) - expect(fs.readFileSync(expectedPath, 'utf8')).toBe(content) - } - ), - {numRuns: 100} - ) - }) - - it('should write child README to correct path', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - subdirNameArb, - readmeContentArb, - async (projectName, subdir, content) => { - const readme = createReadmePrompt(projectName, content, false, tempDir, subdir) - const ctx = createMockWriteContext([readme], tempDir, false) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files.length).toBe(1) - expect(results.files[0].success).toBe(true) - - const expectedPath = path.join(tempDir, projectName, subdir, 'README.md') - expect(fs.existsSync(expectedPath)).toBe(true) - expect(fs.readFileSync(expectedPath, 'utf8')).toBe(content) - } - ), - {numRuns: 100} - ) - }) - - it('should register correct output path per fileKind', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - fileKindArb, - async (projectName, content, fileKind) => { - const readme = createReadmePrompt(projectName, content, true, tempDir, void 0, fileKind) - const ctx = createMockPluginContext([readme], tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - const expectedFileName = README_FILE_KIND_MAP[fileKind].out - - expect(registeredPaths.length).toBe(1) - expect(registeredPaths[0].path).toBe(path.join(projectName, expectedFileName)) - } - ), - {numRuns: 100} - ) - }) - - it('should write correct output file per fileKind', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - fileKindArb, - async (projectName, content, fileKind) => { - const readme = createReadmePrompt(projectName, content, true, tempDir, void 0, fileKind) - const ctx = createMockWriteContext([readme], tempDir, false) - - const results = await plugin.writeProjectOutputs(ctx) - const expectedFileName = README_FILE_KIND_MAP[fileKind].out - - expect(results.files.length).toBe(1) - expect(results.files[0].success).toBe(true) - - const expectedPath = path.join(tempDir, projectName, expectedFileName) - expect(fs.existsSync(expectedPath)).toBe(true) - expect(fs.readFileSync(expectedPath, 'utf8')).toBe(content) - } - ), - {numRuns: 100} - ) - }) - - it('should write all three file kinds to separate files in same project', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readmes = allFileKinds.map(kind => - createReadmePrompt(projectName, `${content}-${kind}`, true, tempDir, void 0, kind)) - const ctx = createMockWriteContext(readmes, tempDir, false) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files.length).toBe(3) - expect(results.files.every(r => r.success)).toBe(true) - - for (const kind of allFileKinds) { - const expectedFileName = README_FILE_KIND_MAP[kind].out - const expectedPath = path.join(tempDir, projectName, expectedFileName) - expect(fs.existsSync(expectedPath)).toBe(true) - expect(fs.readFileSync(expectedPath, 'utf8')).toBe(`${content}-${kind}`) - } - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 4: Dry-Run Idempotence', () => { - const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) - .filter(s => s.trim().length > 0) - - const fileKindArb = fc.constantFrom(...allFileKinds) - - it('should not create any files in dry-run mode', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - fc.boolean(), - fc.option(subdirNameArb, {nil: void 0}), - fileKindArb, - async (projectName, content, isRoot, subdir, fileKind) => { - const readme = createReadmePrompt(projectName, content, isRoot, tempDir, isRoot ? void 0 : subdir ?? 'subdir', fileKind) - const ctx = createMockWriteContext([readme], tempDir, true) - - const filesBefore = fs.readdirSync(tempDir, {recursive: true}) - - await plugin.writeProjectOutputs(ctx) - - const filesAfter = fs.readdirSync(tempDir, {recursive: true}) - expect(filesAfter).toEqual(filesBefore) - } - ), - {numRuns: 100} - ) - }) - - it('should return success results for all planned operations in dry-run mode', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(projectNameArb, {minLength: 1, maxLength: 5}), - readmeContentArb, - async (projectNames, content) => { - const uniqueProjects = [...new Set(projectNames)] - const readmes = uniqueProjects.map(name => - createReadmePrompt(name, content, true, tempDir)) - const ctx = createMockWriteContext(readmes, tempDir, true) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files.length).toBe(uniqueProjects.length) - for (const result of results.files) { - expect(result.success).toBe(true) - expect(result.skipped).toBe(false) - } - } - ), - {numRuns: 100} - ) - }) - - it('should report same operations in dry-run and normal mode', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readme = createReadmePrompt(projectName, content, true, tempDir) - - const dryRunCtx = createMockWriteContext([readme], tempDir, true) - const dryRunResults = await plugin.writeProjectOutputs(dryRunCtx) - - const normalCtx = createMockWriteContext([readme], tempDir, false) - const normalResults = await plugin.writeProjectOutputs(normalCtx) - - expect(dryRunResults.files.length).toBe(normalResults.files.length) - - for (let i = 0; i < dryRunResults.files.length; i++) expect(dryRunResults.files[i].path.path).toBe(normalResults.files[i].path.path) - } - ), - {numRuns: 100} - ) - }) - - it('should not create files in dry-run mode for all fileKinds', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readmes = allFileKinds.map(kind => - createReadmePrompt(projectName, `${content}-${kind}`, true, tempDir, void 0, kind)) - const ctx = createMockWriteContext(readmes, tempDir, true) - - const filesBefore = fs.readdirSync(tempDir, {recursive: true}) - - await plugin.writeProjectOutputs(ctx) - - const filesAfter = fs.readdirSync(tempDir, {recursive: true}) - expect(filesAfter).toEqual(filesBefore) - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 5: Clean Operation Completeness', () => { - const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) - .filter(s => s.trim().length > 0) - - it('should register all output file paths for cleanup', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(projectNameArb, {minLength: 1, maxLength: 5}), - readmeContentArb, - async (projectNames, content) => { - const uniqueProjects = [...new Set(projectNames)] - const readmes = uniqueProjects.map(name => - createReadmePrompt(name, content, true, tempDir)) - const ctx = createMockPluginContext(readmes, tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths.length).toBe(uniqueProjects.length) - - for (const registeredPath of registeredPaths) expect(registeredPath.path.endsWith('README.md')).toBe(true) - } - ), - {numRuns: 100} - ) - }) - - it('should register paths for both root and child READMEs', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - subdirNameArb, - readmeContentArb, - async (projectName, subdir, content) => { - const rootReadme = createReadmePrompt(projectName, content, true, tempDir) - const childReadme = createReadmePrompt(projectName, content, false, tempDir, subdir) - const ctx = createMockPluginContext([rootReadme, childReadme], tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths.length).toBe(2) - - const rootPath = registeredPaths.find(p => p.path === path.join(projectName, 'README.md')) - const childPath = registeredPaths.find(p => p.path === path.join(projectName, subdir, 'README.md')) - - expect(rootPath).toBeDefined() - expect(childPath).toBeDefined() - } - ), - {numRuns: 100} - ) - }) - - it('should return empty array when no README prompts exist', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - async () => { - const ctx = createMockPluginContext([], tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths).toEqual([]) - } - ), - {numRuns: 100} - ) - }) - - it('should register correct paths for all fileKinds', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readmes = allFileKinds.map(kind => - createReadmePrompt(projectName, `${content}-${kind}`, true, tempDir, void 0, kind)) - const ctx = createMockPluginContext(readmes, tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths.length).toBe(3) - - for (const kind of allFileKinds) { - const expectedFileName = README_FILE_KIND_MAP[kind].out - const found = registeredPaths.find(p => p.path === path.join(projectName, expectedFileName)) - expect(found).toBeDefined() - } - } - ), - {numRuns: 100} - ) - }) - }) -}) diff --git a/packages/plugin-readme/src/ReadmeMdConfigFileOutputPlugin.ts b/packages/plugin-readme/src/ReadmeMdConfigFileOutputPlugin.ts deleted file mode 100644 index 44f7fe75..00000000 --- a/packages/plugin-readme/src/ReadmeMdConfigFileOutputPlugin.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { - OutputPluginContext, - OutputWriteContext, - ReadmeFileKind, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' - -import * as fs from 'node:fs' -import * as path from 'node:path' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {FilePathKind, README_FILE_KIND_MAP} from '@truenine/plugin-shared' - -function resolveOutputFileName(fileKind?: ReadmeFileKind): string { - return README_FILE_KIND_MAP[fileKind ?? 'Readme'].out -} - -/** - * Output plugin for writing readme-family files to project directories. - * Reads README prompts collected by ReadmeMdInputPlugin and writes them - * to the corresponding project directories. - * - * Output mapping: - * - fileKind=Readme → README.md - * - fileKind=CodeOfConduct → CODE_OF_CONDUCT.md - * - fileKind=Security → SECURITY.md - * - * Supports: - * - Root files (written to project root) - * - Child files (written to project subdirectories) - * - Dry-run mode (preview without writing) - * - Clean operation (delete generated files) - */ -export class ReadmeMdConfigFileOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('ReadmeMdConfigFileOutputPlugin', {outputFileName: 'README.md'}) - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {readmePrompts} = ctx.collectedInputContext - - if (readmePrompts == null || readmePrompts.length === 0) return results - - for (const readme of readmePrompts) { - const {targetDir} = readme - const outputFileName = resolveOutputFileName(readme.fileKind) - const filePath = path.join(targetDir.path, outputFileName) - - results.push({ - pathKind: FilePathKind.Relative, - path: filePath, - basePath: targetDir.basePath, - getDirectoryName: () => targetDir.getDirectoryName(), - getAbsolutePath: () => path.join(targetDir.basePath, filePath) - }) - } - - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {readmePrompts} = ctx.collectedInputContext - - if (readmePrompts?.length !== 0) return true - - this.log.debug('skipped', {reason: 'no README prompts to write'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - const {readmePrompts} = ctx.collectedInputContext - - if (readmePrompts == null || readmePrompts.length === 0) return {files: fileResults, dirs: dirResults} - - for (const readme of readmePrompts) { - const result = await this.writeReadmeFile(ctx, readme) - fileResults.push(result) - } - - return {files: fileResults, dirs: dirResults} - } - - private async writeReadmeFile( - ctx: OutputWriteContext, - readme: {projectName: string, targetDir: RelativePath, content: unknown, isRoot: boolean, fileKind?: ReadmeFileKind} - ): Promise { - const {targetDir} = readme - const outputFileName = resolveOutputFileName(readme.fileKind) - const filePath = path.join(targetDir.path, outputFileName) - const fullPath = path.join(targetDir.basePath, filePath) - const content = readme.content as string - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: filePath, - basePath: targetDir.basePath, - getDirectoryName: () => targetDir.getDirectoryName(), - getAbsolutePath: () => fullPath - } - - const label = readme.isRoot - ? `project:${readme.projectName}/${outputFileName}` - : `project:${readme.projectName}/${targetDir.path}/${outputFileName}` - - if (ctx.dryRun === true) { // Dry-run mode: log without writing - this.log.trace({action: 'dryRun', type: 'readme', path: fullPath, label}) - return {path: relativePath, success: true, skipped: false} - } - - try { // Actual write operation - const dir = path.dirname(fullPath) // Ensure target directory exists - if (!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true}) - - fs.writeFileSync(fullPath, content, 'utf8') - this.log.trace({action: 'write', type: 'readme', 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: 'readme', path: fullPath, label, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } -} diff --git a/packages/plugin-readme/src/index.ts b/packages/plugin-readme/src/index.ts deleted file mode 100644 index e299d8c0..00000000 --- a/packages/plugin-readme/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - ReadmeMdConfigFileOutputPlugin -} from './ReadmeMdConfigFileOutputPlugin' diff --git a/packages/plugin-readme/tsconfig.eslint.json b/packages/plugin-readme/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-readme/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-readme/tsconfig.json b/packages/plugin-readme/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-readme/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-readme/tsconfig.lib.json b/packages/plugin-readme/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-readme/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-readme/tsconfig.test.json b/packages/plugin-readme/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-readme/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-readme/tsdown.config.ts b/packages/plugin-readme/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-readme/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-readme/vite.config.ts b/packages/plugin-readme/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-readme/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-readme/vitest.config.ts b/packages/plugin-readme/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-readme/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-shared/eslint.config.ts b/packages/plugin-shared/eslint.config.ts deleted file mode 100644 index 2b7b269c..00000000 --- a/packages/plugin-shared/eslint.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' - -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: { - allowDefaultProject: true - } - }, - ignores: [ - '.turbo/**', - '*.md', - '**/*.md' - ] -}) - -export default config as unknown diff --git a/packages/plugin-shared/package.json b/packages/plugin-shared/package.json deleted file mode 100644 index d5d1d23c..00000000 --- a/packages/plugin-shared/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@truenine/plugin-shared", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Shared types, enums, errors, and base classes for memory-sync plugins", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - }, - "./types": { - "types": "./dist/types/index.d.mts", - "import": "./dist/types/index.mjs" - }, - "./testing": { - "types": "./dist/testing/index.d.mts", - "import": "./dist/testing/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": { - "zod": "catalog:" - }, - "devDependencies": { - "@truenine/init-bundle": "workspace:*", - "@truenine/logger": "workspace:*", - "@truenine/md-compiler": "workspace:*", - "fast-check": "catalog:", - "fast-glob": "catalog:" - } -} diff --git a/packages/plugin-shared/src/AbstractPlugin.ts b/packages/plugin-shared/src/AbstractPlugin.ts deleted file mode 100644 index cd9f2b1c..00000000 --- a/packages/plugin-shared/src/AbstractPlugin.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type {ILogger} from './log' -import type {PluginKind} from './types/Enums' -import type {Plugin} from './types/PluginTypes' - -import {createLogger} from './log' - -export abstract class AbstractPlugin implements Plugin { - readonly type: T - - readonly name: string - - private _log?: ILogger - - get log(): ILogger { - this._log ??= createLogger(this.name) - return this._log - } - - readonly dependsOn?: readonly string[] - - protected constructor(name: string, type: T, dependsOn?: readonly string[]) { - this.name = name - this.type = type - if (dependsOn != null) this.dependsOn = dependsOn - } -} diff --git a/packages/plugin-shared/src/PluginNames.ts b/packages/plugin-shared/src/PluginNames.ts deleted file mode 100644 index 8bc93739..00000000 --- a/packages/plugin-shared/src/PluginNames.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const PLUGIN_NAMES = { - AgentsOutput: 'AgentsOutputPlugin', - GeminiCLIOutput: 'GeminiCLIOutputPlugin', - CursorOutput: 'CursorOutputPlugin', - WindsurfOutput: 'WindsurfOutputPlugin', - ClaudeCodeCLIOutput: 'ClaudeCodeCLIOutputPlugin', - KiroIDEOutput: 'KiroCLIOutputPlugin', - OpencodeCLIOutput: 'OpencodeCLIOutputPlugin', - OpenAICodexCLIOutput: 'CodexCLIOutputPlugin', - DroidCLIOutput: 'DroidCLIOutputPlugin', - WarpIDEOutput: 'WarpIDEOutputPlugin', - TraeIDEOutput: 'TraeIDEOutputPlugin', - QoderIDEOutput: 'QoderIDEPluginOutputPlugin', - JetBrainsCodeStyleOutput: 'JetBrainsIDECodeStyleConfigOutputPlugin', - JetBrainsAICodexOutput: 'JetBrainsAIAssistantCodexOutputPlugin', - AgentSkillsCompactOutput: 'GenericSkillsOutputPlugin', - GitExcludeOutput: 'GitExcludeOutputPlugin', - ReadmeOutput: 'ReadmeMdConfigFileOutputPlugin', - VSCodeOutput: 'VisualStudioCodeIDEConfigOutputPlugin', - EditorConfigOutput: 'EditorConfigOutputPlugin', - AntigravityOutput: 'AntigravityOutputPlugin' -} as const - -export type PluginName = (typeof PLUGIN_NAMES)[keyof typeof PLUGIN_NAMES] diff --git a/packages/plugin-shared/src/constants.ts b/packages/plugin-shared/src/constants.ts deleted file mode 100644 index d7e089cb..00000000 --- a/packages/plugin-shared/src/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type {UserConfigFile} from './types/ConfigTypes.schema' -import {bundles, getDefaultConfigContent} from '@truenine/init-bundle' - -export const PathPlaceholders = { - USER_HOME: '~', - WORKSPACE: '$WORKSPACE' -} as const - -type DefaultUserConfig = Readonly>> // Default user config type -const _bundleContent = bundles['public/tnmsc.example.json']?.content ?? getDefaultConfigContent() -export const DEFAULT_USER_CONFIG = JSON.parse(_bundleContent) as DefaultUserConfig // Imported from @truenine/init-bundle package diff --git a/packages/plugin-shared/src/index.ts b/packages/plugin-shared/src/index.ts deleted file mode 100644 index 3b272060..00000000 --- a/packages/plugin-shared/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -export { - AbstractPlugin -} from './AbstractPlugin' -export { - DEFAULT_USER_CONFIG, - PathPlaceholders -} from './constants' -export { - createLogger, - getGlobalLogLevel, - setGlobalLogLevel -} from './log' -export type { - ILogger, - LogLevel -} from './log' -export { - PLUGIN_NAMES -} from './PluginNames' -export type { - PluginName -} from './PluginNames' -export * from './types' diff --git a/packages/plugin-shared/src/log.ts b/packages/plugin-shared/src/log.ts deleted file mode 100644 index 39aa1709..00000000 --- a/packages/plugin-shared/src/log.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - createLogger, - getGlobalLogLevel, - setGlobalLogLevel -} from '@truenine/logger' -export type { - ILogger, - LogLevel -} from '@truenine/logger' diff --git a/packages/plugin-shared/src/testing/index.ts b/packages/plugin-shared/src/testing/index.ts deleted file mode 100644 index d7887558..00000000 --- a/packages/plugin-shared/src/testing/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type {RelativePath} from '../types/FileSystemTypes' -import type {Project, RulePrompt} from '../types/InputTypes' -import {FilePathKind, NamingCaseKind, PromptKind} from '../types/Enums' - -export function createMockRulePrompt( - series: string, - ruleName: string, - seriName: string | undefined, - scope: 'global' | 'project' = 'project' -): RulePrompt { - const content = '# Rule body' - const base = { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: { - pathKind: FilePathKind.Relative, - path: '.', - basePath: '', - getDirectoryName: () => '.', - getAbsolutePath: () => '.' - }, - markdownContents: [], - yamlFrontMatter: { - description: 'Test rule', - globs: ['**/*.ts'], - namingCase: NamingCaseKind.KebabCase - }, - series, - ruleName, - globs: ['**/*.ts'], - scope - } - - return seriName != null - ? {...base, seriName} as RulePrompt - : base as RulePrompt -} - -export function createMockProject( - name: string, - basePath: string, - projectPath: string, - projectConfig?: unknown -): Project { - return { - name, - dirFromWorkspacePath: { - pathKind: FilePathKind.Relative, - path: projectPath, - basePath, - getDirectoryName: () => name, - getAbsolutePath: () => `${basePath}/${projectPath}` - }, - ...projectConfig != null && {projectConfig: projectConfig as never} - } -} - -export function collectFileNames(results: RelativePath[]): string[] { - return results.map(r => { - const parts = r.path.split(/[/\\]/) - return parts.at(-1) ?? r.path - }) -} diff --git a/packages/plugin-shared/src/types/ConfigTypes.schema.property.test.ts b/packages/plugin-shared/src/types/ConfigTypes.schema.property.test.ts deleted file mode 100644 index 2f0ccff3..00000000 --- a/packages/plugin-shared/src/types/ConfigTypes.schema.property.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -import {ZProjectConfig, ZTypeSeriesConfig} from './ConfigTypes.schema' - -describe('zProjectConfig property tests', () => { // Property 7: Zod schema round-trip. Validates: Requirement 1.5 - const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // alphanumeric series names - .filter(s => /^[\w-]+$/.test(s) && s !== '__proto__' && s !== 'constructor' && s !== 'prototype') - - const includeSeriesArb = fc.option( // optional string[] - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}), - {nil: void 0} - ) - - const subSeriesArb = fc.option( // optional Record - fc.dictionary( - seriesNameArb, - fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), - {minKeys: 0, maxKeys: 3} - ), - {nil: void 0} - ) - - function stripUndefined(obj: Record): Record { // strip undefined to match Zod output - const result: Record = {} - for (const [key, value] of Object.entries(obj)) { - if (value !== void 0) result[key] = value - } - return result - } - - const typeSeriesConfigArb = fc.option( // optional TypeSeriesConfig - fc.record({ - includeSeries: includeSeriesArb, - subSeries: subSeriesArb - }).map(obj => stripUndefined(obj)), - {nil: void 0} - ) - - const projectConfigArb = fc.record({ // valid ProjectConfig (no mcp for simplicity) - includeSeries: includeSeriesArb, - subSeries: subSeriesArb, - rules: typeSeriesConfigArb, - skills: typeSeriesConfigArb, - subAgents: typeSeriesConfigArb, - commands: typeSeriesConfigArb - }).map(obj => stripUndefined(obj)) - - it('property 7: round-trip through JSON serialization preserves equivalence', () => { // Validates: Requirement 1.5 - fc.assert( - fc.property( - projectConfigArb, - config => { - const json = JSON.stringify(config) - const parsed = ZProjectConfig.parse(JSON.parse(json)) - expect(parsed).toEqual(config) - } - ), - {numRuns: 200} - ) - }) - - it('property 7: rejects configurations with incorrect includeSeries types', () => { // Validates: Requirement 1.5 - fc.assert( - fc.property( - fc.oneof( - fc.integer(), - fc.boolean(), - fc.constant('not-an-array') - ), - invalidValue => { - expect(() => ZProjectConfig.parse({includeSeries: invalidValue})).toThrow() - } - ), - {numRuns: 50} - ) - }) - - it('property 7: ZTypeSeriesConfig round-trip through JSON serialization', () => { // Validates: Requirement 1.4 - fc.assert( - fc.property( - typeSeriesConfigArb.filter((v): v is Record => v !== void 0), - config => { - const json = JSON.stringify(config) - const parsed = ZTypeSeriesConfig.parse(JSON.parse(json)) - expect(parsed).toEqual(config) - } - ), - {numRuns: 200} - ) - }) -}) diff --git a/packages/plugin-shared/src/types/ConfigTypes.schema.ts b/packages/plugin-shared/src/types/ConfigTypes.schema.ts deleted file mode 100644 index 33925197..00000000 --- a/packages/plugin-shared/src/types/ConfigTypes.schema.ts +++ /dev/null @@ -1,122 +0,0 @@ -import {z} from 'zod/v3' - -/** - * Zod schema for a source/dist path pair. - * Both paths are relative to the shadow source project root. - */ -export const ZShadowSourceProjectDirPair = z.object({ - /** Source path (human-authored .cn.mdx files) */ - src: z.string(), - /** Output/compiled path (read by input plugins) */ - dist: z.string() -}) - -/** - * Zod schema for the shadow source project configuration. - * All paths are relative to `/`. - */ -export const ZShadowSourceProjectConfig = z.object({ - name: z.string(), - skill: ZShadowSourceProjectDirPair, - fastCommand: ZShadowSourceProjectDirPair, - subAgent: ZShadowSourceProjectDirPair, - rule: ZShadowSourceProjectDirPair, - globalMemory: ZShadowSourceProjectDirPair, - workspaceMemory: ZShadowSourceProjectDirPair, - project: ZShadowSourceProjectDirPair -}) - -/** - * Zod schema for per-plugin fast command series override options - */ -export const ZFastCommandSeriesPluginOverride = z.object({ - includeSeriesPrefix: z.boolean().optional(), - seriesSeparator: z.string().optional() -}) - -/** - * Zod schema for fast command series configuration options - */ -export const ZFastCommandSeriesOptions = z.object({ - includeSeriesPrefix: z.boolean().optional(), - pluginOverrides: z.record(z.string(), ZFastCommandSeriesPluginOverride).optional() -}) - -/** - * Zod schema for user profile information - */ -export const ZUserProfile = z.object({ - name: z.string().optional(), - username: z.string().optional(), - gender: z.string().optional(), - birthday: z.string().optional() -}).catchall(z.unknown()) - -/** - * Zod schema for the user configuration file (.tnmsc.json). - * All fields are optional — missing fields use default values. - */ -export const ZUserConfigFile = z.object({ - version: z.string().optional(), - workspaceDir: z.string().optional(), - shadowSourceProject: ZShadowSourceProjectConfig.optional(), - logLevel: z.enum(['trace', 'debug', 'info', 'warn', 'error']).optional(), - fastCommandSeriesOptions: ZFastCommandSeriesOptions.optional(), - profile: ZUserProfile.optional() -}) - -/** - * Zod schema for MCP project config - */ -export const ZMcpProjectConfig = z.object({names: z.array(z.string()).optional()}) - -/** - * Zod schema for per-type series filtering configuration. - * Shared by all four prompt type sections (rules, skills, subAgents, commands). - */ -export const ZTypeSeriesConfig = z.object({ - includeSeries: z.array(z.string()).optional(), - subSeries: z.record(z.string(), z.array(z.string())).optional() -}) - -/** - * Zod schema for project config - */ -export const ZProjectConfig = z.object({ - mcp: ZMcpProjectConfig.optional(), - includeSeries: z.array(z.string()).optional(), - subSeries: z.record(z.string(), z.array(z.string())).optional(), - rules: ZTypeSeriesConfig.optional(), - skills: ZTypeSeriesConfig.optional(), - subAgents: ZTypeSeriesConfig.optional(), - commands: ZTypeSeriesConfig.optional() -}) - -/** - * Zod schema for ConfigLoader options - */ -export const ZConfigLoaderOptions = z.object({ - configFileName: z.string().optional(), - searchPaths: z.array(z.string()).optional(), - searchCwd: z.boolean().optional(), - searchGlobal: z.boolean().optional() -}) - -export type ShadowSourceProjectDirPair = z.infer -export type ShadowSourceProjectConfig = z.infer -export type FastCommandSeriesPluginOverride = z.infer -export type FastCommandSeriesOptions = z.infer -export type UserConfigFile = z.infer -export type McpProjectConfig = z.infer -export type TypeSeriesConfig = z.infer -export type ProjectConfig = z.infer -export type ConfigLoaderOptions = z.infer - -/** - * Result of loading a config file - */ -export interface ConfigLoadResult { - readonly config: UserConfigFile - readonly source: string | null - readonly found: boolean -} diff --git a/packages/plugin-shared/src/types/Enums.ts b/packages/plugin-shared/src/types/Enums.ts deleted file mode 100644 index 6b6db7b3..00000000 --- a/packages/plugin-shared/src/types/Enums.ts +++ /dev/null @@ -1,75 +0,0 @@ -export enum PluginKind { - Input = 'Input', - Output = 'Output' -} - -export enum PromptKind { - GlobalMemory = 'GlobalMemory', - ProjectRootMemory = 'ProjectRootMemory', - ProjectChildrenMemory = 'ProjectChildrenMemory', - FastCommand = 'FastCommand', - SubAgent = 'SubAgent', - Skill = 'Skill', - SkillChildDoc = 'SkillChildDoc', - SkillResource = 'SkillResource', - SkillMcpConfig = 'SkillMcpConfig', - Readme = 'Readme', - Rule = 'Rule' -} - -/** - * Scope for rule application - */ -export type RuleScope = 'project' | 'global' - -export enum ClaudeCodeCLISubAgentColors { - Red = 'Red', - Green = 'Green', - Blue = 'Blue', - Yellow = 'Yellow' -} - -/** - * Tools callable by AI Agent - */ -export enum CodingAgentTools { - Read = 'Read', - Write = 'Write', - Edit = 'Edit', - Grep = 'Grep' -} - -/** - * Naming convention - */ -export enum NamingCaseKind { - CamelCase = 'CamelCase', - PascalCase = 'PascalCase', - SnakeCase = 'SnakeCase', - KebabCase = 'KebabCase', - UpperCase = 'UpperCase', - LowerCase = 'LowerCase', - Original = 'Original' -} - -export enum GlobalConfigDirectoryType { - UserHome = 'UserHome', - External = 'External' -} - -/** - * Directory path kind - */ -export enum FilePathKind { - Relative = 'Relative', - Absolute = 'Absolute', - Root = 'Root' -} - -export enum IDEKind { - VSCode = 'VSCode', - IntellijIDEA = 'IntellijIDEA', - Git = 'Git', - EditorConfig = 'EditorConfig', - Original = 'Original' -} diff --git a/packages/plugin-shared/src/types/Errors.ts b/packages/plugin-shared/src/types/Errors.ts deleted file mode 100644 index 1379295d..00000000 --- a/packages/plugin-shared/src/types/Errors.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Error thrown when a circular dependency is detected in the plugin graph. - */ -export class CircularDependencyError extends Error { - constructor(public readonly cycle: string[]) { - super(`Circular dependency detected: ${cycle.join(' -> ')}`) - this.name = 'CircularDependencyError' - } -} - -/** - * Error thrown when a plugin depends on a non-existent plugin. - */ -export class MissingDependencyError extends Error { - constructor( - public readonly pluginName: string, - public readonly missingDependency: string - ) { - super(`Plugin "${pluginName}" depends on non-existent plugin "${missingDependency}"`) - this.name = 'MissingDependencyError' - } -} - -/** - * Configuration validation error - * Error thrown when configuration file contains invalid fields - */ -export class ConfigValidationError extends Error { - constructor( - readonly field: string, - readonly reason: string, - readonly filePath?: string - ) { - const msg = filePath != null && filePath.length > 0 - ? `Invalid configuration field "${field}": ${reason} (file: ${filePath})` - : `Invalid configuration field "${field}": ${reason}` - super(msg) - this.name = 'ConfigValidationError' - } -} diff --git a/packages/plugin-shared/src/types/ExportMetadataTypes.ts b/packages/plugin-shared/src/types/ExportMetadataTypes.ts deleted file mode 100644 index 361e0c60..00000000 --- a/packages/plugin-shared/src/types/ExportMetadataTypes.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Export metadata types for MDX files - * These interfaces define the expected structure of export statements in MDX files - * that are used as front matter metadata. - * - * @module ExportMetadataTypes - */ - -import type {CodingAgentTools, NamingCaseKind, RuleScope} from './Enums' - -/** - * Base export metadata interface - * All export metadata types should extend this - */ -export interface BaseExportMetadata { - readonly namingCase?: NamingCaseKind -} - -export interface SkillExportMetadata extends BaseExportMetadata { - readonly name: string - readonly description: string - readonly keywords?: readonly string[] - readonly enabled?: boolean - readonly displayName?: string - readonly author?: string - readonly version?: string - readonly allowTools?: readonly (CodingAgentTools | string)[] -} - -export interface FastCommandExportMetadata extends BaseExportMetadata { - readonly description?: string - readonly argumentHint?: string - readonly allowTools?: readonly (CodingAgentTools | string)[] - readonly globalOnly?: boolean -} - -export interface RuleExportMetadata extends BaseExportMetadata { - readonly globs: readonly string[] - readonly description: string - readonly scope?: RuleScope - readonly seriName?: string -} - -export interface SubAgentExportMetadata extends BaseExportMetadata { - readonly name: string - readonly description: string - readonly role?: string - readonly model?: string - readonly color?: string - readonly argumentHint?: string - readonly allowTools?: readonly (CodingAgentTools | string)[] -} - -/** - * Metadata validation result - */ -export interface MetadataValidationResult { - readonly valid: boolean - readonly errors: readonly string[] - readonly warnings: readonly string[] -} - -/** - * Options for metadata validation - */ -export interface ValidateMetadataOptions { - readonly requiredFields: readonly (keyof T)[] - readonly optionalDefaults?: Partial - readonly filePath?: string | undefined -} - -export function validateExportMetadata( - metadata: Record, - options: ValidateMetadataOptions -): MetadataValidationResult { - const {requiredFields, optionalDefaults, filePath} = options - const errors: string[] = [] - const warnings: string[] = [] - - for (const field of requiredFields) { // Check required fields - const fieldName = String(field) - if (!(fieldName in metadata) || metadata[fieldName] == null) { - const errorMsg = filePath != null - ? `Missing required field "${fieldName}" in ${filePath}` - : `Missing required field "${fieldName}"` - errors.push(errorMsg) - } - } - - if (optionalDefaults != null) { // Check optional fields and record warnings for defaults - for (const [key, defaultValue] of Object.entries(optionalDefaults)) { - if (!(key in metadata) || metadata[key] == null) { - const warningMsg = filePath != null - ? `Using default value for optional field "${key}": ${JSON.stringify(defaultValue)} in ${filePath}` - : `Using default value for optional field "${key}": ${JSON.stringify(defaultValue)}` - warnings.push(warningMsg) - } - } - } - - return { - valid: errors.length === 0, - errors, - warnings - } -} - -/** - * Validate skill export metadata - * - * @param metadata - The metadata object to validate - * @param filePath - Optional file path for error messages - * @returns Validation result - */ -export function validateSkillMetadata( - metadata: Record, - filePath?: string -): MetadataValidationResult { - return validateExportMetadata(metadata, { - requiredFields: ['name', 'description'], - optionalDefaults: { - enabled: true, - keywords: [] - }, - filePath - }) -} - -/** - * Validate fast command export metadata - * - * @param metadata - The metadata object to validate - * @param filePath - Optional file path for error messages - * @returns Validation result - */ -export function validateFastCommandMetadata( - metadata: Record, - filePath?: string -): MetadataValidationResult { - return validateExportMetadata(metadata, { // description is optional (can come from YAML or be omitted) // FastCommand has no required fields from export metadata - requiredFields: [], - optionalDefaults: {}, - filePath - }) -} - -/** - * Validate sub-agent export metadata - * - * @param metadata - The metadata object to validate - * @param filePath - Optional file path for error messages - * @returns Validation result - */ -export function validateSubAgentMetadata( - metadata: Record, - filePath?: string -): MetadataValidationResult { - return validateExportMetadata(metadata, { - requiredFields: ['name', 'description'], - optionalDefaults: {}, - filePath - }) -} - -/** - * 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, seriName} = 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}`) - - if (seriName != null && typeof seriName !== 'string') errors.push(`Field "seriName" must be a string${prefix}`) - - return {valid: errors.length === 0, errors, warnings} -} - -/** - * Apply default values to metadata - * - * @param metadata - The metadata object - * @param defaults - Default values to apply - * @returns Metadata with defaults applied - */ -export function applyMetadataDefaults( - metadata: Record, - defaults: Partial -): T { - const result = {...metadata} - - for (const [key, defaultValue] of Object.entries(defaults)) { - if (!(key in result) || result[key] == null) result[key] = defaultValue - } - - return result as T -} diff --git a/packages/plugin-shared/src/types/FileSystemTypes.ts b/packages/plugin-shared/src/types/FileSystemTypes.ts deleted file mode 100644 index 8528424e..00000000 --- a/packages/plugin-shared/src/types/FileSystemTypes.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type {FilePathKind} from './Enums' - -/** - * Common directory representation - */ -export interface Path { - readonly pathKind: K - readonly path: string - readonly getDirectoryName: () => string -} - -/** - * Relative path directory - */ -export interface RelativePath extends Path { - readonly basePath: string - getAbsolutePath: () => string -} - -/** - * Absolute path directory - */ -export type AbsolutePath = Path - -export type RootPath = Path - -export interface FileContent< - C = unknown, - FK extends FilePathKind = FilePathKind.Relative, - F extends Path = RelativePath -> { - content: C - length: number - filePathKind: FK - dir: F - charsetEncoding?: BufferEncoding -} diff --git a/packages/plugin-shared/src/types/InputTypes.ts b/packages/plugin-shared/src/types/InputTypes.ts deleted file mode 100644 index 9f67eed8..00000000 --- a/packages/plugin-shared/src/types/InputTypes.ts +++ /dev/null @@ -1,417 +0,0 @@ -import type {ProjectConfig} from './ConfigTypes.schema' -import type { - FilePathKind, - IDEKind, - PromptKind, - RuleScope -} from './Enums' -import type {FileContent, Path, RelativePath} from './FileSystemTypes' -import type { - FastCommandYAMLFrontMatter, - GlobalMemoryPrompt, - ProjectChildrenMemoryPrompt, - ProjectRootMemoryPrompt, - Prompt, - RuleYAMLFrontMatter, - SkillYAMLFrontMatter, - SubAgentYAMLFrontMatter -} from './PromptTypes' - -export interface Project { - readonly name?: string - readonly dirFromWorkspacePath?: RelativePath - readonly rootMemoryPrompt?: ProjectRootMemoryPrompt - readonly childMemoryPrompts?: readonly ProjectChildrenMemoryPrompt[] - readonly isPromptSourceProject?: boolean - readonly projectConfig?: ProjectConfig -} - -export interface Workspace { - readonly directory: Path - readonly projects: Project[] -} - -/** - * IDE configuration file - */ -export interface ProjectIDEConfigFile extends FileContent { - readonly type: I -} - -/** - * AI Agent ignore configuration file - */ -export interface AIAgentIgnoreConfigFile { - readonly fileName: string - readonly content: string -} - -/** - * All collected output information, provided to plugin system as input for output plugins - */ -export interface CollectedInputContext { - readonly workspace: Workspace - readonly vscodeConfigFiles?: readonly ProjectIDEConfigFile[] - readonly jetbrainsConfigFiles?: readonly ProjectIDEConfigFile[] - readonly editorConfigFiles?: readonly ProjectIDEConfigFile[] - 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 - readonly shadowGitExclude?: string - readonly shadowSourceProjectDir?: string - 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 seriName?: string | string[] | null - readonly rawMdxContent?: string -} - -/** - * Fast command prompt - */ -export interface FastCommandPrompt extends Prompt { - readonly type: PromptKind.FastCommand - readonly globalOnly?: true - readonly series?: string - readonly commandName: string - readonly seriName?: string | string[] | null - readonly rawMdxContent?: string -} - -/** - * Sub-agent prompt - */ -export interface SubAgentPrompt extends Prompt { - readonly type: PromptKind.SubAgent - readonly series?: string - readonly agentName: string - readonly seriName?: string | string[] | null - readonly rawMdxContent?: string -} - -/** - * Skill child document (.md files in skill directory or any subdirectory) - * Excludes skill.md which is the main skill file - */ -export interface SkillChildDoc extends Prompt { - readonly type: PromptKind.SkillChildDoc - readonly relativePath: string -} - -/** - * Resource content encoding type - */ -export type SkillResourceEncoding = 'text' | 'base64' - -/** - * Resource category for classification - * - * Categories: - * - code: .kt, .java, .py, .ts, .js, .go, .rs, etc. - * - data: .sql, .json, .xml, .yaml, .csv, etc. - * - document: .txt, .rtf, .docx, .pdf, etc. - * - config: .ini, .conf, .properties, etc. - * - script: .sh, .bash, .ps1, .bat, etc. - * - image: .png, .jpg, .gif, .svg, .webp, etc. - * - binary: .exe, .dll, .so, .wasm, etc. - * - other: anything else - */ -export type SkillResourceCategory - = | 'code' - | 'data' - | 'document' - | 'config' - | 'script' - | 'image' - | 'binary' - | 'other' - -/** - * Skill resource file for AI on-demand access - * Any non-.md file in skill directory or subdirectories - * - * Supports: - * - Code files: .kt, .java, .py, .ts, .js, .go, .rs, .c, .cpp, etc. - * - Data files: .sql, .json, .xml, .yaml, .csv, etc. - * - Documents: .txt, .rtf, .docx, .pdf, etc. - * - Config files: .ini, .conf, .properties, etc. - * - Scripts: .sh, .bash, .ps1, .bat, etc. - * - Images: .png, .jpg, .gif, .svg, .webp, etc. - * - Binary files: .exe, .dll, .wasm, etc. - */ -export interface SkillResource { - readonly type: PromptKind.SkillResource - readonly extension: string - readonly fileName: string - readonly relativePath: string - readonly content: string - readonly encoding: SkillResourceEncoding - readonly category: SkillResourceCategory - readonly length: number - readonly mimeType?: string -} - -/** - * Text file extensions that should be read as UTF-8 - */ -export const SKILL_RESOURCE_TEXT_EXTENSIONS = [ - '.kt', // Code files - '.java', - '.py', - '.pyi', - '.pyx', - '.ts', - '.tsx', - '.js', - '.jsx', - '.mjs', - '.cjs', - '.go', - '.rs', - '.c', - '.cpp', - '.cc', - '.h', - '.hpp', - '.hxx', - '.cs', - '.fs', - '.fsx', - '.vb', - '.rb', - '.php', - '.swift', - '.scala', - '.groovy', - '.lua', - '.r', - '.R', - '.jl', - '.ex', - '.exs', - '.erl', - '.clj', - '.cljs', - '.hs', - '.ml', - '.mli', - '.nim', - '.zig', - '.v', - '.dart', - '.vue', - '.svelte', - '.sql', // Data files - '.json', - '.jsonc', - '.json5', - '.xml', - '.xsd', - '.xsl', - '.xslt', - '.yaml', - '.yml', - '.toml', - '.csv', - '.tsv', - '.graphql', - '.gql', - '.proto', - '.txt', // Document files - '.text', - '.rtf', - '.log', - '.ini', // Config files - '.conf', - '.cfg', - '.config', - '.properties', - '.env', - '.envrc', - '.editorconfig', - '.gitignore', - '.gitattributes', - '.npmrc', - '.nvmrc', - '.npmignore', - '.eslintrc', - '.prettierrc', - '.stylelintrc', - '.babelrc', - '.browserslistrc', - '.sh', // Script files - '.bash', - '.zsh', - '.fish', - '.ps1', - '.psm1', - '.psd1', - '.bat', - '.cmd', - '.html', // Web files - '.htm', - '.xhtml', - '.css', - '.scss', - '.sass', - '.less', - '.styl', - '.svg', - '.ejs', // Template files - '.hbs', - '.mustache', - '.pug', - '.jade', - '.jinja', - '.jinja2', - '.j2', - '.erb', - '.haml', - '.slim', - '.d.ts', // Declaration files - '.d.mts', - '.d.cts', - '.diff', // Other text formats - '.patch', - '.asm', - '.s', - '.makefile', - '.mk', - '.dockerfile', - '.tf', - '.tfvars', // Terraform - '.prisma', // Prisma - '.mdx' // MDX (but not .md which is handled separately) -] as const - -/** - * Binary file extensions that should be read as base64 - */ -export const SKILL_RESOURCE_BINARY_EXTENSIONS = [ - '.docx', // Documents - '.doc', - '.xlsx', - '.xls', - '.pptx', - '.ppt', - '.pdf', - '.odt', - '.ods', - '.odp', - '.png', // Images - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.ico', - '.bmp', - '.tiff', - '.zip', // Archives - '.tar', - '.gz', - '.bz2', - '.7z', - '.rar', - '.pyd', // Compiled - '.pyc', - '.pyo', - '.class', - '.jar', - '.war', - '.dll', - '.so', - '.dylib', - '.exe', - '.bin', - '.wasm', - '.ttf', // Fonts - '.otf', - '.woff', - '.woff2', - '.eot', - '.mp3', // Audio/Video (usually not needed but for completeness) - '.wav', - '.ogg', - '.mp4', - '.webm', - '.db', // Database - '.sqlite', - '.sqlite3' -] as const - -export type SkillResourceTextExtension = typeof SKILL_RESOURCE_TEXT_EXTENSIONS[number] -export type SkillResourceBinaryExtension = typeof SKILL_RESOURCE_BINARY_EXTENSIONS[number] - -/** - * MCP server configuration entry - */ -export interface McpServerConfig { - readonly command: string - readonly args?: readonly string[] - readonly env?: Readonly> - readonly disabled?: boolean - readonly autoApprove?: readonly string[] -} - -/** - * Skill MCP configuration (mcp.json) - * - Kiro: supports per-power MCP configuration natively - * - Others: may support lazy loading in the future - */ -export interface SkillMcpConfig { - readonly type: PromptKind.SkillMcpConfig - readonly mcpServers: Readonly> - readonly rawContent: string -} - -export interface SkillPrompt extends Prompt { - readonly type: PromptKind.Skill - readonly dir: RelativePath - readonly yamlFrontMatter: SkillYAMLFrontMatter - readonly mcpConfig?: SkillMcpConfig - readonly childDocs?: SkillChildDoc[] - readonly resources?: SkillResource[] - readonly seriName?: string | string[] | null -} - -/** - * Readme-family source file kind - * - * - Readme: rdm.mdx → README.md - * - CodeOfConduct: coc.mdx → CODE_OF_CONDUCT.md - * - Security: security.mdx → SECURITY.md - */ -export type ReadmeFileKind = 'Readme' | 'CodeOfConduct' | 'Security' - -/** - * Mapping from ReadmeFileKind to source/output file names - */ -export const README_FILE_KIND_MAP: Readonly> = { - Readme: {src: 'rdm.mdx', out: 'README.md'}, - CodeOfConduct: {src: 'coc.mdx', out: 'CODE_OF_CONDUCT.md'}, - Security: {src: 'security.mdx', out: 'SECURITY.md'} -} - -/** - * README-family prompt data structure (README.md, CODE_OF_CONDUCT.md, SECURITY.md) - */ -export interface ReadmePrompt extends Prompt { - readonly type: PromptKind.Readme - readonly projectName: string - readonly targetDir: RelativePath - readonly isRoot: boolean - readonly fileKind: ReadmeFileKind -} diff --git a/packages/plugin-shared/src/types/OutputTypes.ts b/packages/plugin-shared/src/types/OutputTypes.ts deleted file mode 100644 index 1e21c3c7..00000000 --- a/packages/plugin-shared/src/types/OutputTypes.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {GlobalConfigDirectoryType} from './Enums' -import type {AbsolutePath, RelativePath} from './FileSystemTypes' - -/** - * Global configuration based on user_home root directory - */ -export interface GlobalConfigDirectoryInUserHome { - readonly type: K - readonly directory: RelativePath -} - -/** - * Special, absolute path global memory prompt - */ -export interface GlobalConfigDirectoryInOther { - readonly type: K - readonly directory: AbsolutePath -} - -export type GlobalConfigDirectory = GlobalConfigDirectoryInUserHome | GlobalConfigDirectoryInOther - -export interface Target { - -} diff --git a/packages/plugin-shared/src/types/PluginTypes.ts b/packages/plugin-shared/src/types/PluginTypes.ts deleted file mode 100644 index 0e2cf619..00000000 --- a/packages/plugin-shared/src/types/PluginTypes.ts +++ /dev/null @@ -1,390 +0,0 @@ -import type {ILogger} from '@truenine/logger' -import type {MdxGlobalScope} from '@truenine/md-compiler/globals' -import type {FastCommandSeriesOptions, ShadowSourceProjectConfig} from './ConfigTypes.schema' -import type {PluginKind} from './Enums' -import type {RelativePath} from './FileSystemTypes' -import type { - CollectedInputContext, - Project -} from './InputTypes' - -/** - * Opaque type for ScopeRegistry. - * Concrete implementation lives in plugin-input-shared. - */ -export interface ScopeRegistryLike { - resolve: (expression: string) => string -} - -export interface Plugin { - readonly type: T - readonly name: string - readonly log: ILogger - readonly dependsOn?: readonly string[] -} - -export interface PluginContext { - logger: ILogger - fs: typeof import('node:fs') - path: typeof import('node:path') - glob: typeof import('fast-glob') -} - -export interface InputPluginContext extends PluginContext { - readonly userConfigOptions: Required - readonly dependencyContext: Partial - - readonly globalScope?: MdxGlobalScope - - readonly scopeRegistry?: ScopeRegistryLike -} - -export interface InputPlugin extends Plugin { - collect: (ctx: InputPluginContext) => Partial | Promise> -} - -/** - * Plugin that can enhance projects after all projects are collected. - * This is useful for plugins that need to add data to projects - * that were collected by other plugins. - */ -export interface ProjectEnhancerPlugin extends InputPlugin { - enhanceProjects: (ctx: InputPluginContext, projects: readonly Project[]) => Project[] -} - -/** - * Context for output plugin operations - */ -export interface OutputPluginContext extends PluginContext { - readonly collectedInputContext: CollectedInputContext - readonly pluginOptions?: PluginOptions -} - -/** - * Context for output cleaning operations - */ -export interface OutputCleanContext extends OutputPluginContext { - readonly dryRun?: boolean -} - -/** - * Context for output writing operations - */ -export interface OutputWriteContext extends OutputPluginContext { - readonly dryRun?: boolean - - readonly registeredPluginNames?: readonly string[] -} - -/** - * Result of a single write operation - */ -export interface WriteResult { - readonly path: RelativePath - readonly success: boolean - readonly skipped?: boolean - readonly error?: Error -} - -/** - * Result of executing a side effect. - * Used for both write and clean effects. - */ -export interface EffectResult { - /** Whether the effect executed successfully */ - readonly success: boolean - /** Error details if the effect failed */ - readonly error?: Error - /** Description of what the effect did (for logging) */ - readonly description?: string -} - -/** - * Collected results from write operations - */ -export interface WriteResults { - readonly files: readonly WriteResult[] - readonly dirs: readonly WriteResult[] -} - -/** - * Awaitable type for sync/async flexibility - */ -export type Awaitable = T | Promise - -/** - * Handler function for write effects. - * Receives the write context and returns an effect result. - */ -export type WriteEffectHandler = (ctx: OutputWriteContext) => Awaitable - -/** - * Handler function for clean effects. - * Receives the clean context and returns an effect result. - */ -export type CleanEffectHandler = (ctx: OutputCleanContext) => Awaitable - -/** - * Result of executing an input effect. - * Used for preprocessing/cleaning input sources before collection. - */ -export interface InputEffectResult { - /** Whether the effect executed successfully */ - readonly success: boolean - /** Error details if the effect failed */ - readonly error?: Error - /** Description of what the effect did (for logging) */ - readonly description?: string - /** Files that were modified/created */ - readonly modifiedFiles?: readonly string[] - /** Files that were deleted */ - readonly deletedFiles?: readonly string[] -} - -/** - * Context provided to input effect handlers. - * Contains utilities and configuration for effect execution. - */ -export interface InputEffectContext { - /** Logger instance */ - readonly logger: ILogger - /** File system module */ - readonly fs: typeof import('node:fs') - /** Path module */ - readonly path: typeof import('node:path') - /** Glob module for file matching */ - readonly glob: typeof import('fast-glob') - /** Child process spawn function */ - readonly spawn: typeof import('node:child_process').spawn - /** User configuration options */ - readonly userConfigOptions: PluginOptions - /** Resolved workspace directory */ - readonly workspaceDir: string - /** Resolved shadow project directory */ - readonly shadowProjectDir: string - /** Whether running in dry-run mode */ - readonly dryRun?: boolean -} - -/** - * Handler function for input effects. - * Receives the effect context and returns an effect result. - */ -export type InputEffectHandler = (ctx: InputEffectContext) => Awaitable - -/** - * Registration entry for an input effect. - */ -export interface InputEffectRegistration { - /** Descriptive name for logging */ - readonly name: string - /** The effect handler function */ - readonly handler: InputEffectHandler - /** Priority for execution order (lower = earlier, default: 0) */ - readonly priority?: number -} - -/** - * Result of resolving base paths from plugin options. - */ -export interface ResolvedBasePaths { - /** The resolved workspace directory path */ - readonly workspaceDir: string - /** The resolved shadow project directory path */ - readonly shadowProjectDir: string -} - -/** - * Represents a registered scope entry from a plugin. - */ -export interface PluginScopeRegistration { - /** The namespace name (e.g., 'myPlugin') */ - readonly namespace: string - /** Key-value pairs registered under this namespace */ - readonly values: Record -} - -/** - * Registration entry for an effect. - */ -export interface EffectRegistration { - /** Descriptive name for logging */ - readonly name: string - /** The effect handler function */ - readonly handler: THandler -} - -/** - * Output plugin interface. - * Plugins directly implement lifecycle hooks as methods. - * All hooks support both sync and async implementations. - */ -export interface OutputPlugin extends Plugin { - registerProjectOutputDirs?: (ctx: OutputPluginContext) => Awaitable - - registerProjectOutputFiles?: (ctx: OutputPluginContext) => Awaitable - - registerGlobalOutputDirs?: (ctx: OutputPluginContext) => Awaitable - - registerGlobalOutputFiles?: (ctx: OutputPluginContext) => Awaitable - - canCleanProject?: (ctx: OutputCleanContext) => Awaitable - - canCleanGlobal?: (ctx: OutputCleanContext) => Awaitable - - onCleanComplete?: (ctx: OutputCleanContext) => Awaitable - - canWrite?: (ctx: OutputWriteContext) => Awaitable - - writeProjectOutputs?: (ctx: OutputWriteContext) => Awaitable - - writeGlobalOutputs?: (ctx: OutputWriteContext) => Awaitable - - onWriteComplete?: (ctx: OutputWriteContext, results: WriteResults) => Awaitable -} - -/** - * Collected outputs from all plugins. - * Used by the clean command to gather all artifacts for cleanup. - */ -export interface CollectedOutputs { - readonly projectDirs: readonly RelativePath[] - readonly projectFiles: readonly RelativePath[] - readonly globalDirs: readonly RelativePath[] - readonly globalFiles: readonly RelativePath[] -} - -/** - * Collect all outputs from all registered output plugins. - * This is the main entry point for the clean command. - */ -export async function collectAllPluginOutputs( - plugins: readonly OutputPlugin[], - ctx: OutputPluginContext -): Promise { - const projectDirs: RelativePath[] = [] - const projectFiles: RelativePath[] = [] - const globalDirs: RelativePath[] = [] - const globalFiles: RelativePath[] = [] - - for (const plugin of plugins) { - if (plugin.registerProjectOutputDirs) projectDirs.push(...await plugin.registerProjectOutputDirs(ctx)) - if (plugin.registerProjectOutputFiles) projectFiles.push(...await plugin.registerProjectOutputFiles(ctx)) - if (plugin.registerGlobalOutputDirs) globalDirs.push(...await plugin.registerGlobalOutputDirs(ctx)) - if (plugin.registerGlobalOutputFiles) globalFiles.push(...await plugin.registerGlobalOutputFiles(ctx)) - } - - return { - projectDirs, - projectFiles, - globalDirs, - globalFiles - } -} - -/** - * Result of checking if a plugin allows cleaning. - */ -export interface CleanPermission { - readonly project: boolean - readonly global: boolean -} - -/** - * Check if all plugins allow cleaning. - * Returns a map of plugin name to whether cleaning is allowed. - */ -export async function checkCanClean( - plugins: readonly OutputPlugin[], - ctx: OutputCleanContext -): Promise> { - const result = new Map() - - for (const plugin of plugins) { - result.set(plugin.name, {project: await plugin.canCleanProject?.(ctx) ?? true, global: await plugin.canCleanGlobal?.(ctx) ?? true}) - } - - return result -} - -/** - * Execute post-clean hooks for all plugins. - */ -export async function executeOnCleanComplete( - plugins: readonly OutputPlugin[], - ctx: OutputCleanContext -): Promise { - for (const plugin of plugins) await plugin.onCleanComplete?.(ctx) -} - -/** - * Result of checking if a plugin allows writing. - */ -export interface WritePermission { - readonly project: boolean - readonly global: boolean -} - -/** - * Check if all plugins allow writing. - * Returns a map of plugin name to whether writing is allowed. - */ -export async function checkCanWrite( - plugins: readonly OutputPlugin[], - ctx: OutputWriteContext -): Promise> { - const result = new Map() - - for (const plugin of plugins) { - const canWrite = await plugin.canWrite?.(ctx) ?? true - result.set(plugin.name, {project: canWrite, global: canWrite}) - } - - return result -} - -/** - * Execute write operations for all plugins. - * Respects dry-run mode in context. - */ -export async function executeWriteOutputs( - plugins: readonly OutputPlugin[], - ctx: OutputWriteContext -): Promise> { - const results = new Map() - - for (const plugin of plugins) { - const projectResults = await plugin.writeProjectOutputs?.(ctx) ?? {files: [], dirs: []} - const globalResults = await plugin.writeGlobalOutputs?.(ctx) ?? {files: [], dirs: []} - - const merged: WriteResults = { - files: [...projectResults.files, ...globalResults.files], - dirs: [...projectResults.dirs, ...globalResults.dirs] - } - - results.set(plugin.name, merged) - await plugin.onWriteComplete?.(ctx, merged) - } - - return results -} - -/** - * Configuration to be processed by plugin.config.ts - * Interpreted by plugin system as collection context - * Path placeholder `~` resolves to the user home directory. - * - * @see CollectedInputContext - Collected context - */ -export interface PluginOptions { - readonly version?: string - - readonly workspaceDir?: string - - readonly shadowSourceProject?: ShadowSourceProjectConfig - - readonly fastCommandSeriesOptions?: FastCommandSeriesOptions - - plugins?: Plugin[] - logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' -} diff --git a/packages/plugin-shared/src/types/PromptTypes.ts b/packages/plugin-shared/src/types/PromptTypes.ts deleted file mode 100644 index 8504237f..00000000 --- a/packages/plugin-shared/src/types/PromptTypes.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type {Root, RootContent} from '@truenine/md-compiler' -import type {ClaudeCodeCLISubAgentColors, CodingAgentTools, FilePathKind, NamingCaseKind, PromptKind, RuleScope} from './Enums' -import type {FileContent, Path, RelativePath, RootPath} from './FileSystemTypes' -import type {GlobalConfigDirectory} from './OutputTypes' - -/** - * Prompt - */ -export interface Prompt< - T extends PromptKind = PromptKind, - Y extends YAMLFrontMatter = YAMLFrontMatter, - DK extends FilePathKind = FilePathKind.Relative, - D extends Path = RelativePath, - C = unknown -> extends FileContent { - readonly type: T - readonly yamlFrontMatter?: Y - readonly rawFrontMatter?: string - readonly markdownAst?: Root - readonly markdownContents: readonly RootContent[] - readonly dir: D -} - -export interface YAMLFrontMatter extends Record { - readonly namingCase: N -} - -export interface CommonYAMLFrontMatter extends YAMLFrontMatter { - readonly description: string -} - -export interface ToolAwareYAMLFrontMatter extends CommonYAMLFrontMatter { - readonly allowTools?: (CodingAgentTools | string)[] - readonly argumentHint?: string -} - -/** - * Memory prompt working on project root directory - */ -export interface ProjectRootMemoryPrompt extends Prompt< - PromptKind.ProjectRootMemory, - YAMLFrontMatter, - FilePathKind.Relative, - RootPath -> { - readonly type: PromptKind.ProjectRootMemory -} - -/** - * Memory prompt working on project subdirectory - */ -export interface ProjectChildrenMemoryPrompt extends Prompt { - readonly type: PromptKind.ProjectChildrenMemory - readonly workingChildDirectoryPath: RelativePath -} - -export interface SubAgentYAMLFrontMatter extends ToolAwareYAMLFrontMatter { - readonly name: string - readonly model?: string - readonly color?: ClaudeCodeCLISubAgentColors | string - readonly seriName?: string | string[] | null -} - -export interface FastCommandYAMLFrontMatter extends ToolAwareYAMLFrontMatter { - readonly seriName?: string | string[] | null -} // description, argumentHint, allowTools inherited from ToolAwareYAMLFrontMatter - -/** - * Base YAML front matter for all skill types - */ -export interface SkillsYAMLFrontMatter extends CommonYAMLFrontMatter { - readonly name: string -} - -export interface SkillYAMLFrontMatter extends SkillsYAMLFrontMatter { - readonly allowTools?: (CodingAgentTools | string)[] - readonly keywords?: readonly string[] - readonly displayName?: string - readonly author?: string - readonly version?: string - readonly seriName?: string | string[] | null -} - -/** - * Codex skill metadata field - * Follows Agent Skills specification: https://agentskills.io/specification - * - * The metadata field is an arbitrary key-value mapping for additional metadata. - * Common fields include displayName, version, author, keywords, etc. - */ -export interface CodexSkillMetadata { - readonly 'short-description'?: string - readonly 'displayName'?: string - readonly 'version'?: string - readonly 'author'?: string - readonly 'keywords'?: readonly string[] - readonly 'category'?: string - readonly 'repository'?: string - readonly [key: string]: unknown -} - -export interface CodexSkillYAMLFrontMatter extends SkillsYAMLFrontMatter { - readonly 'license'?: string - readonly 'compatibility'?: string - readonly 'metadata'?: CodexSkillMetadata - readonly 'allowed-tools'?: string -} - -/** - * Kiro steering file front matter - * @see https://kiro.dev/docs/steering - */ -export interface KiroSteeringYAMLFrontMatter extends YAMLFrontMatter { - readonly inclusion?: 'always' | 'fileMatch' | 'manual' - readonly fileMatchPattern?: string -} - -/** - * Kiro Power POWER.md front matter - * @see https://kiro.dev/docs/powers - */ -export interface KiroPowerYAMLFrontMatter extends SkillsYAMLFrontMatter { - readonly displayName?: string - readonly keywords?: readonly string[] - readonly author?: string -} - -/** - * Rule YAML front matter with glob patterns and scope - */ -export interface RuleYAMLFrontMatter extends CommonYAMLFrontMatter { - readonly globs: readonly string[] - readonly scope?: RuleScope - readonly seriName?: string | string[] | null -} - -/** - * Global memory prompt - * Single output target - */ -export interface GlobalMemoryPrompt extends Prompt< - PromptKind.GlobalMemory -> { - readonly type: PromptKind.GlobalMemory - readonly parentDirectoryPath: GlobalConfigDirectory -} diff --git a/packages/plugin-shared/src/types/RegistryTypes.ts b/packages/plugin-shared/src/types/RegistryTypes.ts deleted file mode 100644 index 0054f51a..00000000 --- a/packages/plugin-shared/src/types/RegistryTypes.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Registry Configuration Writer Types - * - * Type definitions for registry data structures used by output plugins - * to register their outputs in external tool registry files. - * - * @see Requirements 2.1, 2.2, 2.3, 3.1, 3.2, 3.3, 3.5 - */ - -/** - * Generic registry data structure. - * All registry files must have version and lastUpdated fields. - * - * @see Requirements 1.8 - */ -export interface RegistryData { - readonly version: string - readonly lastUpdated: string -} - -/** - * Result of a registry operation. - * - * @see Requirements 5.4 - */ -export interface RegistryOperationResult { - readonly success: boolean - readonly entryName: string - readonly error?: Error -} - -/** - * Source information for a Kiro power. - * Indicates the origin type of a registered power. - * - * @see Requirements 3.1, 3.2 - */ -export interface KiroPowerSource { - readonly type: 'local' | 'repo' | 'registry' - readonly repoId?: string - readonly repoName?: string - readonly cloneId?: string -} - -/** - * A single power entry in the Kiro registry. - * Contains metadata about an installed power. - * - * Field order matches Kiro's expected format: - * name → description → mcpServers → author → keywords → displayName → installed → installedAt → installPath → source → sourcePath - * - * @see Requirements 2.1, 2.2, 2.3, 2.4 - */ -export interface KiroPowerEntry { - readonly name: string - readonly description: string - readonly mcpServers?: readonly string[] - readonly author?: string - readonly keywords: readonly string[] - readonly displayName?: string - readonly installed: boolean - readonly installedAt?: string - readonly installPath?: string - readonly source: KiroPowerSource - readonly sourcePath?: string -} - -/** - * Repository source tracking in Kiro registry. - * Tracks the source/origin of registered items. - * - * @see Requirements 3.1, 3.2, 3.3, 3.5 - */ -export interface KiroRepoSource { - readonly name: string - readonly type: 'local' | 'git' - readonly enabled: boolean - readonly addedAt?: string - readonly powerCount: number - readonly path?: string - readonly lastSync?: string - readonly powers?: readonly string[] -} - -/** - * Kiro recommended repo metadata (preserved during updates). - * - * @see Requirements 4.5, 4.6 - */ -export interface KiroRecommendedRepo { - readonly url: string - readonly lastFetch: string - readonly powerCount: number -} - -/** - * Complete Kiro powers registry structure. - * Represents the full ~/.kiro/powers/registry.json file. - * - * @see Requirements 4.1, 4.2 - */ -export interface KiroPowersRegistry extends RegistryData { - readonly powers: Record - readonly repoSources: Record - readonly kiroRecommendedRepo?: KiroRecommendedRepo -} diff --git a/packages/plugin-shared/src/types/ShadowSourceProjectTypes.ts b/packages/plugin-shared/src/types/ShadowSourceProjectTypes.ts deleted file mode 100644 index 61a8788e..00000000 --- a/packages/plugin-shared/src/types/ShadowSourceProjectTypes.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * Shadow Source Project (aindex) directory structure types and constants - * Used for directory structure validation and generation - */ - -/** - * File entry in the shadow source project - */ -export interface ShadowSourceFileEntry { - /** File name (e.g., 'GLOBAL.md') */ - readonly name: string - /** Whether this file is required */ - readonly required: boolean - /** File description */ - readonly description?: string -} - -/** - * Directory entry in the shadow source project - */ -export interface ShadowSourceDirectoryEntry { - /** Directory name (e.g., 'skills') */ - readonly name: string - /** Whether this directory is required */ - readonly required: boolean - /** Directory description */ - readonly description?: string - /** Nested directories */ - readonly directories?: readonly ShadowSourceDirectoryEntry[] - /** Files in this directory */ - readonly files?: readonly ShadowSourceFileEntry[] -} - -/** - * Root structure of the shadow source project - */ -export interface ShadowSourceProjectDirectory { - /** Source directories (before compilation) */ - readonly src: { - readonly skills: ShadowSourceDirectoryEntry - readonly commands: ShadowSourceDirectoryEntry - readonly agents: ShadowSourceDirectoryEntry - readonly rules: ShadowSourceDirectoryEntry - readonly globalMemoryFile: ShadowSourceFileEntry - readonly workspaceMemoryFile: ShadowSourceFileEntry - } - /** Distribution directories (after compilation) */ - readonly dist: { - readonly skills: ShadowSourceDirectoryEntry - readonly commands: ShadowSourceDirectoryEntry - readonly agents: ShadowSourceDirectoryEntry - readonly rules: ShadowSourceDirectoryEntry - readonly app: ShadowSourceDirectoryEntry - readonly globalMemoryFile: ShadowSourceFileEntry - readonly workspaceMemoryFile: ShadowSourceFileEntry - } - /** App directory (project-specific prompts source, standalone at root) */ - readonly app: ShadowSourceDirectoryEntry - /** IDE configuration directories */ - readonly ide: { - readonly idea: ShadowSourceDirectoryEntry - readonly ideaCodeStyles: ShadowSourceDirectoryEntry - readonly vscode: ShadowSourceDirectoryEntry - } - /** IDE configuration files */ - readonly ideFiles: readonly ShadowSourceFileEntry[] - /** AI Agent ignore files */ - readonly ignoreFiles: readonly ShadowSourceFileEntry[] -} - -/** - * Directory names used in shadow source project - */ -export const SHADOW_SOURCE_DIR_NAMES = { - SRC: 'src', - DIST: 'dist', - SKILLS: 'skills', - COMMANDS: 'commands', - AGENTS: 'agents', - RULES: 'rules', - APP: 'app', - IDEA: '.idea', // IDE directories - IDEA_CODE_STYLES: '.idea/codeStyles', - VSCODE: '.vscode' -} as const - -/** - * File names used in shadow source project - */ -export const SHADOW_SOURCE_FILE_NAMES = { - GLOBAL_MEMORY: 'global.mdx', // Global memory - GLOBAL_MEMORY_SRC: 'global.cn.mdx', - WORKSPACE_MEMORY: 'workspace.mdx', // Workspace memory - WORKSPACE_MEMORY_SRC: 'workspace.cn.mdx', - EDITOR_CONFIG: '.editorconfig', // EditorConfig - IDEA_GITIGNORE: '.idea/.gitignore', // JetBrains IDE - IDEA_PROJECT_XML: '.idea/codeStyles/Project.xml', - IDEA_CODE_STYLE_CONFIG_XML: '.idea/codeStyles/codeStyleConfig.xml', - VSCODE_SETTINGS: '.vscode/settings.json', // VS Code - VSCODE_EXTENSIONS: '.vscode/extensions.json', - QODER_IGNORE: '.qoderignore', // AI Agent ignore files - CURSOR_IGNORE: '.cursorignore', - WARP_INDEX_IGNORE: '.warpindexignore', - AI_IGNORE: '.aiignore', - CODEIUM_IGNORE: '.codeiumignore' // Windsurf ignore file -} as const - -/** - * Relative paths from shadow source project root - */ -export const SHADOW_SOURCE_RELATIVE_PATHS = { - SRC_SKILLS: 'src/skills', // Source paths - SRC_COMMANDS: 'src/commands', - SRC_AGENTS: 'src/agents', - SRC_RULES: 'src/rules', - SRC_GLOBAL_MEMORY: 'app/global.cn.mdx', - SRC_WORKSPACE_MEMORY: 'app/workspace.cn.mdx', - DIST_SKILLS: 'dist/skills', // Distribution paths - DIST_COMMANDS: 'dist/commands', - DIST_AGENTS: 'dist/agents', - DIST_RULES: 'dist/rules', - DIST_APP: 'dist/app', - DIST_GLOBAL_MEMORY: 'dist/global.mdx', - DIST_WORKSPACE_MEMORY: 'dist/app/workspace.mdx', - APP: 'app' // App source path (standalone at root) -} as const - -/** - * Default shadow source project directory structure - * Used for validation and generation - */ -export const DEFAULT_SHADOW_SOURCE_PROJECT_STRUCTURE: ShadowSourceProjectDirectory = { - src: { - skills: { - name: SHADOW_SOURCE_DIR_NAMES.SKILLS, - required: false, - description: 'Skill source files (.cn.mdx)' - }, - commands: { - name: SHADOW_SOURCE_DIR_NAMES.COMMANDS, - required: false, - description: 'Fast command source files (.cn.mdx)' - }, - agents: { - name: SHADOW_SOURCE_DIR_NAMES.AGENTS, - required: false, - description: 'Sub-agent source files (.cn.mdx)' - }, - rules: { - name: SHADOW_SOURCE_DIR_NAMES.RULES, - required: false, - description: 'Rule source files (.cn.mdx)' - }, - globalMemoryFile: { - name: SHADOW_SOURCE_FILE_NAMES.GLOBAL_MEMORY_SRC, - required: false, - description: 'Global memory source file' - }, - workspaceMemoryFile: { - name: SHADOW_SOURCE_FILE_NAMES.WORKSPACE_MEMORY_SRC, - required: false, - description: 'Workspace memory source file' - } - }, - dist: { - skills: { - name: SHADOW_SOURCE_DIR_NAMES.SKILLS, - required: false, - description: 'Compiled skill files (.mdx)' - }, - commands: { - name: SHADOW_SOURCE_DIR_NAMES.COMMANDS, - required: false, - description: 'Compiled fast command files (.mdx)' - }, - agents: { - name: SHADOW_SOURCE_DIR_NAMES.AGENTS, - required: false, - description: 'Compiled sub-agent files (.mdx)' - }, - rules: { - name: SHADOW_SOURCE_DIR_NAMES.RULES, - required: false, - description: 'Compiled rule files (.mdx)' - }, - globalMemoryFile: { - name: SHADOW_SOURCE_FILE_NAMES.GLOBAL_MEMORY, - required: false, - description: 'Compiled global memory file' - }, - workspaceMemoryFile: { - name: SHADOW_SOURCE_FILE_NAMES.WORKSPACE_MEMORY, - required: false, - description: 'Compiled workspace memory file' - }, - app: { - name: SHADOW_SOURCE_DIR_NAMES.APP, - required: false, - description: 'Compiled project-specific prompts' - } - }, - app: { - name: SHADOW_SOURCE_DIR_NAMES.APP, - required: false, - description: 'Project-specific prompts (standalone directory)' - }, - ide: { - idea: { - name: SHADOW_SOURCE_DIR_NAMES.IDEA, - required: false, - description: 'JetBrains IDE configuration directory' - }, - ideaCodeStyles: { - name: SHADOW_SOURCE_DIR_NAMES.IDEA_CODE_STYLES, - required: false, - description: 'JetBrains IDE code styles directory' - }, - vscode: { - name: SHADOW_SOURCE_DIR_NAMES.VSCODE, - required: false, - description: 'VS Code configuration directory' - } - }, - ideFiles: [ - { - name: SHADOW_SOURCE_FILE_NAMES.EDITOR_CONFIG, - required: false, - description: 'EditorConfig file' - }, - { - name: SHADOW_SOURCE_FILE_NAMES.IDEA_GITIGNORE, - required: false, - description: 'JetBrains IDE .gitignore' - }, - { - name: SHADOW_SOURCE_FILE_NAMES.IDEA_PROJECT_XML, - required: false, - description: 'JetBrains IDE Project.xml' - }, - { - name: SHADOW_SOURCE_FILE_NAMES.IDEA_CODE_STYLE_CONFIG_XML, - required: false, - description: 'JetBrains IDE codeStyleConfig.xml' - }, - { - name: SHADOW_SOURCE_FILE_NAMES.VSCODE_SETTINGS, - required: false, - description: 'VS Code settings.json' - }, - { - name: SHADOW_SOURCE_FILE_NAMES.VSCODE_EXTENSIONS, - required: false, - description: 'VS Code extensions.json' - } - ], - ignoreFiles: [ - { - name: SHADOW_SOURCE_FILE_NAMES.QODER_IGNORE, - required: false, - description: 'Qoder ignore file' - }, - { - name: SHADOW_SOURCE_FILE_NAMES.CURSOR_IGNORE, - required: false, - description: 'Cursor ignore file' - }, - { - name: SHADOW_SOURCE_FILE_NAMES.WARP_INDEX_IGNORE, - required: false, - description: 'Warp index ignore file' - }, - { - name: SHADOW_SOURCE_FILE_NAMES.AI_IGNORE, - required: false, - description: 'AI ignore file' - }, - { - name: SHADOW_SOURCE_FILE_NAMES.CODEIUM_IGNORE, - required: false, - description: 'Windsurf ignore file' - } - ] -} as const - -/** - * Type for directory names - */ -export type ShadowSourceDirName = (typeof SHADOW_SOURCE_DIR_NAMES)[keyof typeof SHADOW_SOURCE_DIR_NAMES] - -/** - * Type for file names - */ -export type ShadowSourceFileName = (typeof SHADOW_SOURCE_FILE_NAMES)[keyof typeof SHADOW_SOURCE_FILE_NAMES] - -/** - * Type for relative paths - */ -export type ShadowSourceRelativePath = (typeof SHADOW_SOURCE_RELATIVE_PATHS)[keyof typeof SHADOW_SOURCE_RELATIVE_PATHS] diff --git a/packages/plugin-shared/src/types/index.ts b/packages/plugin-shared/src/types/index.ts deleted file mode 100644 index 7d516e26..00000000 --- a/packages/plugin-shared/src/types/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export * from './ConfigTypes.schema' -export * from './Enums' -export * from './Errors' -export * from './ExportMetadataTypes' -export * from './FileSystemTypes' -export * from './InputTypes' -export * from './OutputTypes' -export * from './PluginTypes' -export * from './PromptTypes' -export * from './RegistryTypes' -export * from './ShadowSourceProjectTypes' diff --git a/packages/plugin-shared/src/types/seriNamePropagation.property.test.ts b/packages/plugin-shared/src/types/seriNamePropagation.property.test.ts deleted file mode 100644 index 340e7c90..00000000 --- a/packages/plugin-shared/src/types/seriNamePropagation.property.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** Property 8: seriName front matter propagation. Validates: Requirement 2.5 */ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) - -const seriNameArb: fc.Arbitrary = fc.oneof( - fc.constant(null), - fc.constant(void 0), - seriesNameArb, - fc.array(seriesNameArb, {minLength: 1, maxLength: 5}) -) - -function propagateSeriName( - frontMatter: {readonly seriName?: string | string[] | null} | undefined -): {readonly seriName?: string | string[] | null} { - const seriName = frontMatter?.seriName - return { - ...seriName != null && {seriName} - } -} - -describe('property 8: seriName front matter propagation', () => { - it('propagated seriName matches front matter value for non-null/undefined values', () => { // **Validates: Requirement 2.5** - fc.assert( - fc.property( - seriNameArb, - seriName => { - const frontMatter = seriName === void 0 ? {} : {seriName} - const result = propagateSeriName(frontMatter) - - if (seriName == null) { - expect(result.seriName).toBeUndefined() // null and undefined should not appear on the prompt object - } else { - expect(result.seriName).toEqual(seriName) // string and string[] should be propagated exactly - } - } - ), - {numRuns: 200} - ) - }) - - it('undefined front matter produces no seriName on prompt', () => { // **Validates: Requirement 2.5** - fc.assert( - fc.property( - fc.constant(void 0), - frontMatter => { - const result = propagateSeriName(frontMatter) - expect(result.seriName).toBeUndefined() - } - ), - {numRuns: 10} - ) - }) - - it('string seriName is always propagated identically', () => { // **Validates: Requirement 2.5** - fc.assert( - fc.property( - seriesNameArb, - seriName => { - const result = propagateSeriName({seriName}) - expect(result.seriName).toBe(seriName) - } - ), - {numRuns: 200} - ) - }) - - it('array seriName is always propagated identically', () => { // **Validates: Requirement 2.5** - fc.assert( - fc.property( - fc.array(seriesNameArb, {minLength: 1, maxLength: 5}), - seriName => { - const result = propagateSeriName({seriName}) - expect(result.seriName).toEqual(seriName) - } - ), - {numRuns: 200} - ) - }) -}) diff --git a/packages/plugin-shared/tsconfig.eslint.json b/packages/plugin-shared/tsconfig.eslint.json deleted file mode 100644 index 585b38ee..00000000 --- a/packages/plugin-shared/tsconfig.eslint.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "skipLibCheck": true - }, - "include": [ - "src/**/*.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "env.d.ts", - "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" - ], - "exclude": [ - "../node_modules", - "dist", - "coverage" - ] -} diff --git a/packages/plugin-shared/tsconfig.json b/packages/plugin-shared/tsconfig.json deleted file mode 100644 index 03cd50a3..00000000 --- a/packages/plugin-shared/tsconfig.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": [ - "ESNext" - ], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { - "@/*": [ - "./src/*" - ] - }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": [ - "src/**/*", - "env.d.ts", - "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" - ], - "exclude": [ - "../node_modules", - "dist" - ] -} diff --git a/packages/plugin-shared/tsconfig.lib.json b/packages/plugin-shared/tsconfig.lib.json deleted file mode 100644 index b2449b37..00000000 --- a/packages/plugin-shared/tsconfig.lib.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "noEmit": false, - "outDir": "../dist", - "skipLibCheck": true - }, - "include": [ - "src/**/*", - "env.d.ts" - ], - "exclude": [ - "../node_modules", - "dist", - "**/*.spec.ts", - "**/*.test.ts" - ] -} diff --git a/packages/plugin-shared/tsconfig.test.json b/packages/plugin-shared/tsconfig.test.json deleted file mode 100644 index 65c3c9ad..00000000 --- a/packages/plugin-shared/tsconfig.test.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "lib": [ - "ESNext", - "DOM" - ], - "types": [ - "vitest/globals", - "node" - ] - }, - "include": [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "vitest.config.ts", - "vite.config.ts", - "env.d.ts" - ], - "exclude": [ - "../node_modules", - "dist" - ] -} diff --git a/packages/plugin-shared/tsdown.config.ts b/packages/plugin-shared/tsdown.config.ts deleted file mode 100644 index acb18d61..00000000 --- a/packages/plugin-shared/tsdown.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: [ - './src/index.ts', - './src/types/index.ts', - './src/testing/index.ts', - '!**/*.{spec,test}.*' - ], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: { - '@': resolve('src') - }, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-shared/vite.config.ts b/packages/plugin-shared/vite.config.ts deleted file mode 100644 index 2dcc5646..00000000 --- a/packages/plugin-shared/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - } - } -}) diff --git a/packages/plugin-shared/vitest.config.ts b/packages/plugin-shared/vitest.config.ts deleted file mode 100644 index a06eb3a7..00000000 --- a/packages/plugin-shared/vitest.config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {fileURLToPath} from 'node:url' - -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' - -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: { - enabled: true, - tsconfig: './tsconfig.test.json' - }, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/', - 'dist/', - '**/*.test.ts', - '**/*.property.test.ts' - ] - } - } - }) -) diff --git a/packages/plugin-trae-ide/eslint.config.ts b/packages/plugin-trae-ide/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-trae-ide/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-trae-ide/package.json b/packages/plugin-trae-ide/package.json deleted file mode 100644 index b02b4ee8..00000000 --- a/packages/plugin-trae-ide/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-trae-ide", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Trae IDE output plugin", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-trae-ide/src/TraeIDEOutputPlugin.test.ts b/packages/plugin-trae-ide/src/TraeIDEOutputPlugin.test.ts deleted file mode 100644 index 07a71e45..00000000 --- a/packages/plugin-trae-ide/src/TraeIDEOutputPlugin.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type {FastCommandPrompt, OutputWriteContext, Project, ProjectChildrenMemoryPrompt, RelativePath, WriteResult} from '@truenine/plugin-shared' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' -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/packages/plugin-trae-ide/src/TraeIDEOutputPlugin.ts b/packages/plugin-trae-ide/src/TraeIDEOutputPlugin.ts deleted file mode 100644 index 971eb8c3..00000000 --- a/packages/plugin-trae-ide/src/TraeIDEOutputPlugin.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { - FastCommandPrompt, - OutputPluginContext, - OutputWriteContext, - Project, - ProjectChildrenMemoryPrompt, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import * as path from 'node:path' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {filterCommandsByProjectConfig} from '@truenine/plugin-output-shared/utils' - -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'}) - } - - protected override getIgnoreOutputPath(): string | undefined { - if (this.indexignore == null) return void 0 - return path.join('.trae', '.ignore') - } - - 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 projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const steeringDir = this.getGlobalSteeringDir() - const results: RelativePath[] = [] - - if (globalMemory != null) results.push(this.createRelativePath(GLOBAL_MEMORY_FILE, steeringDir, () => STEERING_SUBDIR)) - - if (fastCommands == null) return results - - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) 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 projectConfig = this.resolvePromptSourceProjectConfig(ctx) - 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) return {files: fileResults, dirs: []} - - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) 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/packages/plugin-trae-ide/src/index.ts b/packages/plugin-trae-ide/src/index.ts deleted file mode 100644 index d194f82b..00000000 --- a/packages/plugin-trae-ide/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - TraeIDEOutputPlugin -} from './TraeIDEOutputPlugin' diff --git a/packages/plugin-trae-ide/tsconfig.eslint.json b/packages/plugin-trae-ide/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-trae-ide/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-trae-ide/tsconfig.json b/packages/plugin-trae-ide/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-trae-ide/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-trae-ide/tsconfig.lib.json b/packages/plugin-trae-ide/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-trae-ide/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-trae-ide/tsconfig.test.json b/packages/plugin-trae-ide/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-trae-ide/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-trae-ide/tsdown.config.ts b/packages/plugin-trae-ide/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-trae-ide/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-trae-ide/vite.config.ts b/packages/plugin-trae-ide/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-trae-ide/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-trae-ide/vitest.config.ts b/packages/plugin-trae-ide/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-trae-ide/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-vscode/eslint.config.ts b/packages/plugin-vscode/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-vscode/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-vscode/package.json b/packages/plugin-vscode/package.json deleted file mode 100644 index ffce1870..00000000 --- a/packages/plugin-vscode/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-vscode", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "VS Code IDE config output plugin for memory-sync", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-vscode/src/VisualStudioCodeIDEConfigOutputPlugin.ts b/packages/plugin-vscode/src/VisualStudioCodeIDEConfigOutputPlugin.ts deleted file mode 100644 index f58a4eaf..00000000 --- a/packages/plugin-vscode/src/VisualStudioCodeIDEConfigOutputPlugin.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { - OutputPluginContext, - OutputWriteContext, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {FilePathKind, IDEKind} from '@truenine/plugin-shared' - -const VSCODE_DIR = '.vscode' - -/** - * Default VS Code config files that this plugin manages. - * These are the relative paths within each project directory. - */ -const VSCODE_CONFIG_FILES = [ - '.vscode/settings.json', - '.vscode/extensions.json' -] as const - -export class VisualStudioCodeIDEConfigOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('VisualStudioCodeIDEConfigOutputPlugin') - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - const {vscodeConfigFiles} = ctx.collectedInputContext - - const hasVSCodeConfigs = vscodeConfigFiles != null && vscodeConfigFiles.length > 0 - if (!hasVSCodeConfigs) return results - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - - if (project.isPromptSourceProject === true) continue - - for (const configFile of VSCODE_CONFIG_FILES) { - const filePath = this.joinPath(projectDir.path, configFile) - results.push({ - pathKind: FilePathKind.Relative, - path: filePath, - basePath: projectDir.basePath, - getDirectoryName: () => this.dirname(configFile), - getAbsolutePath: () => this.resolvePath(projectDir.basePath, filePath) - }) - } - } - - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const {vscodeConfigFiles} = ctx.collectedInputContext - const hasVSCodeConfigs = vscodeConfigFiles != null && vscodeConfigFiles.length > 0 - - if (hasVSCodeConfigs) return true - - this.log.debug('skipped', {reason: 'no VS Code config files found'}) - return false - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const {projects} = ctx.collectedInputContext.workspace - const {vscodeConfigFiles} = ctx.collectedInputContext - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - const vscodeConfigs = vscodeConfigFiles ?? [] - - for (const project of projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - - const projectName = project.name ?? 'unknown' - - for (const config of vscodeConfigs) { - const result = await this.writeConfigFile(ctx, projectDir, config, `project:${projectName}`) - fileResults.push(result) - } - } - - return {files: fileResults, dirs: dirResults} - } - - private async writeConfigFile( - ctx: OutputWriteContext, - projectDir: RelativePath, - config: {type: IDEKind, content: string, dir: {path: string}}, - label: string - ): Promise { - const targetRelativePath = this.getTargetRelativePath(config) - const fullPath = this.resolvePath(projectDir.basePath, projectDir.path, targetRelativePath) - - const relativePath: RelativePath = { - pathKind: FilePathKind.Relative, - path: this.joinPath(projectDir.path, targetRelativePath), - basePath: projectDir.basePath, - getDirectoryName: () => this.dirname(targetRelativePath), - getAbsolutePath: () => fullPath - } - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'config', path: fullPath, label}) - return {path: relativePath, success: true, skipped: false} - } - - try { - const dir = this.dirname(fullPath) - this.ensureDirectory(dir) - this.writeFileSync(fullPath, config.content) - this.log.trace({action: 'write', type: 'config', 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: 'config', path: fullPath, label, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private getTargetRelativePath(config: {type: IDEKind, dir: {path: string}}): string { - const sourcePath = config.dir.path - - if (config.type !== IDEKind.VSCode) return this.basename(sourcePath) - - const vscodeIndex = sourcePath.indexOf(VSCODE_DIR) - if (vscodeIndex !== -1) return sourcePath.slice(Math.max(0, vscodeIndex)) - return this.joinPath(VSCODE_DIR, this.basename(sourcePath)) - } -} diff --git a/packages/plugin-vscode/src/index.ts b/packages/plugin-vscode/src/index.ts deleted file mode 100644 index c8848542..00000000 --- a/packages/plugin-vscode/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - VisualStudioCodeIDEConfigOutputPlugin -} from './VisualStudioCodeIDEConfigOutputPlugin' diff --git a/packages/plugin-vscode/tsconfig.eslint.json b/packages/plugin-vscode/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-vscode/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-vscode/tsconfig.json b/packages/plugin-vscode/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-vscode/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-vscode/tsconfig.lib.json b/packages/plugin-vscode/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-vscode/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-vscode/tsconfig.test.json b/packages/plugin-vscode/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-vscode/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-vscode/tsdown.config.ts b/packages/plugin-vscode/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-vscode/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-vscode/vite.config.ts b/packages/plugin-vscode/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-vscode/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-vscode/vitest.config.ts b/packages/plugin-vscode/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-vscode/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-warp-ide/eslint.config.ts b/packages/plugin-warp-ide/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-warp-ide/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-warp-ide/package.json b/packages/plugin-warp-ide/package.json deleted file mode 100644 index c0e6a973..00000000 --- a/packages/plugin-warp-ide/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@truenine/plugin-warp-ide", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Warp IDE output plugin", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*" - } -} diff --git a/packages/plugin-warp-ide/src/WarpIDEOutputPlugin.test.ts b/packages/plugin-warp-ide/src/WarpIDEOutputPlugin.test.ts deleted file mode 100644 index c61ea29c..00000000 --- a/packages/plugin-warp-ide/src/WarpIDEOutputPlugin.test.ts +++ /dev/null @@ -1,513 +0,0 @@ -import type { - CollectedInputContext, - GlobalMemoryPrompt, - OutputPluginContext, - OutputWriteContext, - ProjectRootMemoryPrompt, - RelativePath -} from '@truenine/plugin-shared' -import fs from 'node:fs' -import path from 'node:path' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {WarpIDEOutputPlugin} from './WarpIDEOutputPlugin' - -vi.mock('node:fs') // Mock fs module - -describe('warpIDEOutputPlugin', () => { - const mockWorkspaceDir = '/workspace/test' - let plugin: WarpIDEOutputPlugin - - beforeEach(() => { - plugin = new WarpIDEOutputPlugin() - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.mkdirSync).mockReturnValue(void 0) - vi.mocked(fs.writeFileSync).mockReturnValue(void 0) - }) - - 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 createMockRootMemoryPrompt(content: string): ProjectRootMemoryPrompt { - return { - type: PromptKind.ProjectRootMemory, - content, - dir: createMockRelativePath('.', mockWorkspaceDir), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative - } as ProjectRootMemoryPrompt - } - - function createMockGlobalMemoryPrompt(content: string): GlobalMemoryPrompt { - return { - type: PromptKind.GlobalMemory, - content, - dir: createMockRelativePath('.', mockWorkspaceDir), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative, - parentDirectoryPath: { - type: 'UserHome', - directory: createMockRelativePath('.memory', '/home/user') - } - } as GlobalMemoryPrompt - } - - function createMockOutputWriteContext( - collectedInputContext: Partial, - dryRun = false, - registeredPluginNames: readonly string[] = [] - ): OutputWriteContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [] - }, - ideConfigFiles: [], - ...collectedInputContext - } as CollectedInputContext, - dryRun, - registeredPluginNames - } - } - - describe('registerProjectOutputFiles', () => { - it('should register WARP.md for project with rootMemoryPrompt', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('test content') - } - ] - }, - ideConfigFiles: [] - } as CollectedInputContext - } - - const results = await plugin.registerProjectOutputFiles(ctx) - - 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 () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const childDir = createMockRelativePath('project1/src', mockWorkspaceDir) - - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - childMemoryPrompts: [ - { - type: PromptKind.ProjectChildrenMemory, - dir: childDir, - content: 'child content', - workingChildDirectoryPath: childDir, - markdownContents: [], - length: 13, - filePathKind: FilePathKind.Relative - } - ] - } - ] - }, - ideConfigFiles: [] - } as CollectedInputContext - } - - const results = await plugin.registerProjectOutputFiles(ctx) - - 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 () => { - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project' - } - ] - }, - ideConfigFiles: [] - } as CollectedInputContext - } - - const results = await plugin.registerProjectOutputFiles(ctx) - - expect(results).toHaveLength(0) - }) - }) - - describe('canWrite', () => { - it('should return false when AgentsOutputPlugin is registered', async () => { - const ctx = createMockOutputWriteContext( - { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), - rootMemoryPrompt: createMockRootMemoryPrompt('test content') - } - ] as any - } as any - }, - false, - ['AgentsOutputPlugin'] - ) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(false) - }) - - it('should return true when project has rootMemoryPrompt and AgentsOutputPlugin is not registered', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), - rootMemoryPrompt: createMockRootMemoryPrompt('test content') - } - ] as any - } as any - }) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(true) - }) - - it('should return true when project has childMemoryPrompts', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), - childMemoryPrompts: [ - { - type: PromptKind.ProjectChildrenMemory, - dir: createMockRelativePath('project1/src', mockWorkspaceDir), - content: 'child content', - workingChildDirectoryPath: createMockRelativePath('src', mockWorkspaceDir), - markdownContents: [], - length: 13, - filePathKind: FilePathKind.Relative - } - ] - } - ] as any - } as any - }) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(true) - }) - - it('should return false when no outputs exist', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project' - } - ] as any - } as any - }) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(false) - }) - }) - - describe('writeProjectOutputs', () => { - it('should write rootMemoryPrompt without globalMemory', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('# Project Rules\n\nThis is project content.') - } - ] as any - } as any - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(1) - expect(results.files[0].success).toBe(true) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - expect.stringContaining('WARP.md'), - '# Project Rules\n\nThis is project content.', - 'utf8' - ) - }) - - it('should combine globalMemory with rootMemoryPrompt', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const globalMemory = createMockGlobalMemoryPrompt('# Global Rules\n\nThese are global rules.') - const rootMemory = createMockRootMemoryPrompt('# Project Rules\n\nThese are project rules.') - - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: rootMemory - } - ] as any - } as any, - globalMemory - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(1) - expect(results.files[0].success).toBe(true) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - expect.stringContaining('WARP.md'), - '# Global Rules\n\nThese are global rules.\n\n# Project Rules\n\nThese are project rules.', - 'utf8' - ) - }) - - it('should prepend globalMemory to the beginning of rootMemoryPrompt', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const globalContent = 'Global content first' - const projectContent = 'Project content second' - const globalMemory = createMockGlobalMemoryPrompt(globalContent) - const rootMemory = createMockRootMemoryPrompt(projectContent) - - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: rootMemory - } - ] as any - } as any, - globalMemory - }) - - await plugin.writeProjectOutputs(ctx) - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] - const writtenContent = writeCall[1] as string - - const globalIndex = writtenContent.indexOf(globalContent) // Verify global content comes first - const projectIndex = writtenContent.indexOf(projectContent) - - expect(globalIndex).toBeGreaterThanOrEqual(0) - expect(projectIndex).toBeGreaterThan(globalIndex) - expect(writtenContent).toBe(`${globalContent}\n\n${projectContent}`) - }) - - it('should skip globalMemory if it is empty or whitespace', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const globalMemory = createMockGlobalMemoryPrompt(' \n\n ') - const rootMemory = createMockRootMemoryPrompt('# Project Rules') - - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: rootMemory - } - ] as any - } as any, - globalMemory - }) - - await plugin.writeProjectOutputs(ctx) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - expect.stringContaining('WARP.md'), - '# Project Rules', - 'utf8' - ) - }) - - it('should write multiple projects with globalMemory', async () => { - const globalMemory = createMockGlobalMemoryPrompt('Global rules') - - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'project-1', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), - rootMemoryPrompt: createMockRootMemoryPrompt('Project 1 rules') - }, - { - name: 'project-2', - dirFromWorkspacePath: createMockRelativePath('project2', mockWorkspaceDir), - rootMemoryPrompt: createMockRootMemoryPrompt('Project 2 rules') - } - ] as any - } as any, - globalMemory - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(2) - expect(results.files[0].success).toBe(true) - expect(results.files[1].success).toBe(true) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(2) // Verify both files have global memory prepended - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('project1'), - 'Global rules\n\nProject 1 rules', - 'utf8' - ) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('project2'), - 'Global rules\n\nProject 2 rules', - 'utf8' - ) - }) - - it('should not add globalMemory to child prompts', async () => { - const globalMemory = createMockGlobalMemoryPrompt('Global rules') - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const childDir = createMockRelativePath('project1/src', mockWorkspaceDir) - - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('Root rules'), - childMemoryPrompts: [ - { - type: PromptKind.ProjectChildrenMemory, - dir: childDir, - content: 'Child rules', - workingChildDirectoryPath: childDir, - markdownContents: [], - length: 11, - filePathKind: FilePathKind.Relative - } - ] - } - ] as any - } as any, - globalMemory - }) - - await plugin.writeProjectOutputs(ctx) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(2) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( // Root prompt should have global memory - 1, - expect.stringContaining(path.join('project1', 'WARP.md')), - 'Global rules\n\nRoot rules', - 'utf8' - ) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( // Child prompt should NOT have global memory - 2, - expect.stringContaining(path.join('project1', 'src', 'WARP.md')), - 'Child rules', - 'utf8' - ) - }) - - it('should skip project without dirFromWorkspacePath', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - rootMemoryPrompt: createMockRootMemoryPrompt('content') - } - ] as any - } as any - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(0) - expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() - }) - - it('should support dry-run mode', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const ctx = createMockOutputWriteContext( - { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('test content') - } - ] as any - } as any - }, - true - ) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(1) - expect(results.files[0].success).toBe(true) - expect(results.files[0].skipped).toBe(false) - expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() - }) - }) -}) diff --git a/packages/plugin-warp-ide/src/WarpIDEOutputPlugin.ts b/packages/plugin-warp-ide/src/WarpIDEOutputPlugin.ts deleted file mode 100644 index 18f0a8f4..00000000 --- a/packages/plugin-warp-ide/src/WarpIDEOutputPlugin.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { - OutputPluginContext, - OutputWriteContext, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {PLUGIN_NAMES} from '@truenine/plugin-shared' - -const PROJECT_MEMORY_FILE = 'WARP.md' - -export class WarpIDEOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('WarpIDEOutputPlugin', {outputFileName: PROJECT_MEMORY_FILE, indexignore: '.warpindexignore'}) - } - - private isAgentsPluginRegisteredInCtx(ctx: OutputPluginContext | OutputWriteContext): boolean { - if ('registeredPluginNames' in ctx && ctx.registeredPluginNames != null) return ctx.registeredPluginNames.includes(PLUGIN_NAMES.AgentsOutput) - return false - } - - async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {projects} = ctx.collectedInputContext.workspace - const agentsRegistered = this.isAgentsPluginRegisteredInCtx(ctx) - - for (const project of projects) { - if (project.dirFromWorkspacePath == null) continue - - if (agentsRegistered) { - results.push(this.createFileRelativePath(project.dirFromWorkspacePath, PROJECT_MEMORY_FILE)) // When AgentsOutputPlugin is registered, register WARP.md for global prompt output to each project - } else { - if (project.rootMemoryPrompt != null) results.push(this.createFileRelativePath(project.dirFromWorkspacePath, PROJECT_MEMORY_FILE)) // Normal mode: register files for projects with prompts - - if (project.childMemoryPrompts != null) { - for (const child of project.childMemoryPrompts) { - if (child.dir != null && this.isRelativePath(child.dir)) results.push(this.createFileRelativePath(child.dir, PROJECT_MEMORY_FILE)) - } - } - } - } - - results.push(...this.registerProjectIgnoreOutputFiles(projects)) - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - const agentsRegistered = this.shouldSkipDueToPlugin(ctx, PLUGIN_NAMES.AgentsOutput) - const {workspace, globalMemory, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext - - if (agentsRegistered) { - if (globalMemory == null) { // When AgentsOutputPlugin is registered, only write if we have global memory - this.log.debug('skipped', {reason: 'AgentsOutputPlugin registered but no global memory'}) - return false - } - return true - } - - const hasProjectOutputs = workspace.projects.some( // Normal mode: check for project outputs - p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 - ) - - 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 - } - - async writeProjectOutputs(ctx: OutputWriteContext): Promise { - const agentsRegistered = this.shouldSkipDueToPlugin(ctx, PLUGIN_NAMES.AgentsOutput) - const {workspace, globalMemory} = ctx.collectedInputContext - const {projects} = workspace - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - if (agentsRegistered) { - 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) // Normal mode: write combined content - - for (const project of projects) { - const projectName = project.name ?? 'unknown' - const projectDir = project.dirFromWorkspacePath - - if (projectDir == null) continue - - if (project.rootMemoryPrompt != null) { // Write root memory prompt (only if exists) - const combinedContent = this.combineGlobalWithContent( - globalMemoryContent, - project.rootMemoryPrompt.content as string - ) - - const result = await this.writePromptFile(ctx, projectDir, combinedContent, `project:${projectName}/root`) - fileResults.push(result) - } - - if (project.childMemoryPrompts != null) { // Write children memory prompts - for (const child of project.childMemoryPrompts) { - const childResult = await this.writePromptFile(ctx, child.dir, child.content as string, `project:${projectName}/child:${child.workingChildDirectoryPath?.path ?? 'unknown'}`) - fileResults.push(childResult) - } - } - } - - const ignoreResults = await this.writeProjectIgnoreFiles(ctx) - fileResults.push(...ignoreResults) - - return {files: fileResults, dirs: dirResults} - } -} diff --git a/packages/plugin-warp-ide/src/index.ts b/packages/plugin-warp-ide/src/index.ts deleted file mode 100644 index b9e1bf10..00000000 --- a/packages/plugin-warp-ide/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - WarpIDEOutputPlugin -} from './WarpIDEOutputPlugin' diff --git a/packages/plugin-warp-ide/tsconfig.eslint.json b/packages/plugin-warp-ide/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-warp-ide/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-warp-ide/tsconfig.json b/packages/plugin-warp-ide/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-warp-ide/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-warp-ide/tsconfig.lib.json b/packages/plugin-warp-ide/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-warp-ide/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-warp-ide/tsconfig.test.json b/packages/plugin-warp-ide/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-warp-ide/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-warp-ide/tsdown.config.ts b/packages/plugin-warp-ide/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-warp-ide/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-warp-ide/vite.config.ts b/packages/plugin-warp-ide/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-warp-ide/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-warp-ide/vitest.config.ts b/packages/plugin-warp-ide/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-warp-ide/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) diff --git a/packages/plugin-windsurf/eslint.config.ts b/packages/plugin-windsurf/eslint.config.ts deleted file mode 100644 index f7be876f..00000000 --- a/packages/plugin-windsurf/eslint.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import eslint10 from '@truenine/eslint10-config' - -const configDir = dirname(fileURLToPath(import.meta.url)) - -const config = eslint10({ - type: 'lib', - typescript: { - strictTypescriptEslint: true, - tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), - parserOptions: {allowDefaultProject: true} - }, - ignores: ['.turbo/**', '*.md', '**/*.md'] -}) - -export default config as unknown diff --git a/packages/plugin-windsurf/package.json b/packages/plugin-windsurf/package.json deleted file mode 100644 index 4b69a2c7..00000000 --- a/packages/plugin-windsurf/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@truenine/plugin-windsurf", - "type": "module", - "version": "2026.10224.10619", - "private": true, - "description": "Windsurf IDE output plugin", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "scripts": { - "build": "tsdown", - "lint": "eslint --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "check": "run-p typecheck lint", - "lintfix": "eslint --fix --cache .", - "test": "vitest run", - "prepublishOnly": "run-s build" - }, - "dependencies": {}, - "devDependencies": { - "@truenine/md-compiler": "workspace:*", - "@truenine/plugin-output-shared": "workspace:*", - "@truenine/plugin-shared": "workspace:*", - "picomatch": "catalog:" - } -} diff --git a/packages/plugin-windsurf/src/WindsurfOutputPlugin.projectConfig.test.ts b/packages/plugin-windsurf/src/WindsurfOutputPlugin.projectConfig.test.ts deleted file mode 100644 index 9344187b..00000000 --- a/packages/plugin-windsurf/src/WindsurfOutputPlugin.projectConfig.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type {OutputPluginContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared/testing' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' - -class TestableWindsurfOutputPlugin extends WindsurfOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -function createMockContext( - tempDir: string, - rules: unknown[], - projects: unknown[] -): OutputPluginContext { - return { - collectedInputContext: { - workspace: { - projects: projects as never, - directory: { - pathKind: 1, - path: tempDir, - basePath: tempDir, - getDirectoryName: () => 'workspace', - getAbsolutePath: () => tempDir - } - }, - ideConfigFiles: [], - rules: rules as never, - fastCommands: [], - skills: [], - globalMemory: void 0, - aiAgentIgnoreConfigFiles: [] - }, - logger: { - debug: vi.fn(), - trace: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } as never, - fs, - path, - glob: vi.fn() as never - } -} - -describe('windsurfOutputPlugin - projectConfig filtering', () => { - let tempDir: string, - plugin: TestableWindsurfOutputPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-proj-config-test-')) - plugin = new TestableWindsurfOutputPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch {} - }) - - describe('registerProjectOutputFiles', () => { - it('should include all project rules when no projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [createMockProject('proj1', tempDir, 'proj1')] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).toContain('rule-test-rule2.md') - }) - - it('should filter rules by include in projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).not.toContain('rule-test-rule2.md') - }) - - it('should filter rules by includeSeries excluding non-matching series', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).not.toContain('rule-test-rule1.md') - expect(fileNames).toContain('rule-test-rule2.md') - }) - - it('should include rules without seriName regardless of include filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', void 0, 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).not.toContain('rule-test-rule2.md') - }) - - it('should filter independently for each project', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}), - createMockProject('proj2', tempDir, 'proj2', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = results.map(r => ({ - path: r.path, - fileName: r.path.split(/[/\\]/).pop() - })) - - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule1.md')).toBe(true) - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule2.md')).toBe(false) - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule2.md')).toBe(true) - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule1.md')).toBe(false) - }) - - it('should return empty when include matches nothing', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const ruleFiles = results.filter(r => r.path.includes('rule-')) - - expect(ruleFiles).toHaveLength(0) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should not register rules dir when all rules filtered out', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputDirs(ctx) - const rulesDirs = results.filter(r => r.path.includes('rules')) - - expect(rulesDirs).toHaveLength(0) - }) - - it('should register rules dir when rules match filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputDirs(ctx) - const rulesDirs = results.filter(r => r.path.includes('rules')) - - expect(rulesDirs.length).toBeGreaterThan(0) - }) - }) -}) diff --git a/packages/plugin-windsurf/src/WindsurfOutputPlugin.property.test.ts b/packages/plugin-windsurf/src/WindsurfOutputPlugin.property.test.ts deleted file mode 100644 index ad133594..00000000 --- a/packages/plugin-windsurf/src/WindsurfOutputPlugin.property.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -import type {OutputPluginContext, OutputWriteContext, RelativePath} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {createLogger, FilePathKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {describe, it} from 'vitest' -import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -class TestableWindsurfOutputPlugin extends WindsurfOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -const validNameGen = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generators for property-based tests - .filter(s => /^[\w-]+$/.test(s)) - .map(s => s.toLowerCase()) - -const skillNameGen = validNameGen.filter(name => name.length > 0 && name !== 'create-rule' && name !== 'create-skill') - -const commandNameGen = validNameGen.filter(name => name.length > 0) - -const seriesNameGen = fc.option(validNameGen, {nil: void 0}) - -const fileContentGen = fc.string({minLength: 0, maxLength: 500}) - -describe('windsurf output plugin property tests', () => { - describe('registerGlobalOutputDirs', () => { - it('should always return empty array when no inputs provided', async () => { - await fc.assert( - fc.asyncProperty(fc.string({minLength: 1}), async _basePath => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return results.length === 0 - }) - ) - }) - - it('should always register at least one dir when fastCommands exist', async () => { - await fc.assert( - fc.asyncProperty( - commandNameGen, - seriesNameGen, - async (commandName, series) => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const fastCommand = { - type: PromptKind.FastCommand, - commandName, - series, - content: 'Test content', - length: 12, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir), - markdownContents: [], - yamlFrontMatter: {description: 'Test command', namingCase: 'kebab-case'} - } - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - fastCommands: [fastCommand], - skills: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return results.length >= 1 && results.some(r => r.path === 'global_workflows') - } - ) - ) - }) - - it('should always register at least one dir when skills exist', async () => { - await fc.assert( - fc.asyncProperty( - skillNameGen, - async skillName => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const skill = { - yamlFrontMatter: {name: skillName, description: 'Test skill', namingCase: 'kebab-case'}, - dir: createMockRelativePath(skillName, tempDir), - content: '# Test Skill', - length: 12, - type: PromptKind.Skill, - filePathKind: FilePathKind.Relative, - markdownContents: [] - } - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [skill], - fastCommands: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return results.length >= 1 && results.some(r => r.path.startsWith('skills')) - } - ) - ) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should always return empty array when no inputs provided', async () => { - await fc.assert( - fc.asyncProperty(fc.string({minLength: 1}), async _basePath => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return results.length === 0 - }) - ) - }) - - it('should register one file per fastCommand', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(commandNameGen, {minLength: 1, maxLength: 5}), - async commandNames => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const fastCommands = commandNames.map(name => ({ - type: PromptKind.FastCommand, - commandName: name, - content: 'Test content', - length: 12, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir), - markdownContents: [], - yamlFrontMatter: {description: 'Test command', namingCase: 'kebab-case'} - })) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - fastCommands, - skills: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - const workflowFiles = results.filter(r => r.path.startsWith('global_workflows')) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return workflowFiles.length === commandNames.length - } - ) - ) - }) - }) - - describe('canWrite', () => { - it('should return true when any content exists', async () => { - await fc.assert( - fc.asyncProperty( - fc.boolean(), - fc.boolean(), - fc.boolean(), - async (hasSkills, hasFastCommands, hasGlobalMemory) => { - if (!hasSkills && !hasFastCommands && !hasGlobalMemory) return true // Skip if all are false - - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: hasSkills - ? [{yamlFrontMatter: {name: 'test-skill', description: 'Test', namingCase: 'kebab-case'}}] - : [], - fastCommands: hasFastCommands - ? [{commandName: 'test', yamlFrontMatter: {description: 'Test', namingCase: 'kebab-case'}}] - : [], - globalMemory: hasGlobalMemory - ? {content: 'Global rules', length: 12, type: PromptKind.GlobalMemory} - : null - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return result - } - ) - ) - }) - }) - - describe('writeGlobalOutputs dry-run property', () => { - it('should not modify filesystem when dryRun is true', async () => { - await fc.assert( - fc.asyncProperty( - fileContentGen, - async content => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const initialFiles = fs.existsSync(tempDir) // Capture initial state - ? fs.readdirSync(tempDir) - : [] - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: { - type: PromptKind.GlobalMemory, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir), - markdownContents: [] - }, - skills: [], - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: true - } as unknown as OutputWriteContext - - await plugin.writeGlobalOutputs(ctx) - - const finalFiles = fs.existsSync(tempDir) // Verify filesystem unchanged - ? fs.readdirSync(tempDir) - : [] - - fs.rmSync(tempDir, {recursive: true, force: true}) - return JSON.stringify(initialFiles) === JSON.stringify(finalFiles) - } - ) - ) - }) - }) - - describe('writeProjectOutputs', () => { - it('should always return empty results regardless of input', async () => { - await fc.assert( - fc.asyncProperty( - fc.boolean(), - fc.boolean(), - async (hasProjects, hasGlobalMemory) => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const projects = hasProjects - ? [{name: 'project-a', dirFromWorkspacePath: createMockRelativePath('project-a', tempDir)}] - : [] - - const ctx = { - collectedInputContext: { - workspace: {projects, directory: createMockRelativePath('.', tempDir)}, - globalMemory: hasGlobalMemory - ? {content: 'Global rules', length: 12, type: PromptKind.GlobalMemory} - : null - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeProjectOutputs(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return results.files.length === 0 && results.dirs.length === 0 - } - ) - ) - }) - }) - - describe('output path consistency', () => { - it('should generate consistent base paths for all outputs', async () => { - await fc.assert( - fc.asyncProperty( - skillNameGen, - commandNameGen, - async (skillName, commandName) => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const skill = { - yamlFrontMatter: {name: skillName, description: 'Test skill', namingCase: 'kebab-case'}, - dir: createMockRelativePath(skillName, tempDir), - content: '# Test Skill', - length: 12, - type: PromptKind.Skill, - filePathKind: FilePathKind.Relative, - markdownContents: [] - } - - const fastCommand = { - type: PromptKind.FastCommand, - commandName, - content: 'Test content', - length: 12, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir), - markdownContents: [], - yamlFrontMatter: {description: 'Test command', namingCase: 'kebab-case'} - } - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [skill], - fastCommands: [fastCommand] - } - } as unknown as OutputPluginContext - - const dirs = await plugin.registerGlobalOutputDirs(ctx) - const files = await plugin.registerGlobalOutputFiles(ctx) - - const basePaths = [...dirs, ...files].map(r => r.basePath) - const allSameBase = basePaths.every(bp => bp === basePaths[0]) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return allSameBase && basePaths[0].includes('.codeium') - } - ) - ) - }) - }) -}) diff --git a/packages/plugin-windsurf/src/WindsurfOutputPlugin.test.ts b/packages/plugin-windsurf/src/WindsurfOutputPlugin.test.ts deleted file mode 100644 index 40be0eb5..00000000 --- a/packages/plugin-windsurf/src/WindsurfOutputPlugin.test.ts +++ /dev/null @@ -1,677 +0,0 @@ -import type { - FastCommandPrompt, - GlobalMemoryPrompt, - OutputPluginContext, - OutputWriteContext, - RelativePath, - RulePrompt, - SkillPrompt -} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {createLogger, FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it} from 'vitest' -import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -function createMockRulePrompt( - series: string, - ruleName: string, - globs: readonly string[], - scope: 'global' | 'project', - seriName?: string -): RulePrompt { - const content = '# Rule body\n\nFollow this rule.' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', ''), - markdownContents: [], - yamlFrontMatter: { - description: 'Rule description', - globs, - namingCase: NamingCaseKind.KebabCase - }, - series, - ruleName, - globs, - scope, - ...seriName != null && {seriName} - } as RulePrompt -} - -function createMockGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt { - return { - type: PromptKind.GlobalMemory, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', basePath), - markdownContents: [], - parentDirectoryPath: { - type: 'UserHome', - directory: createMockRelativePath('.codeium/windsurf', basePath) - } - } as GlobalMemoryPrompt -} - -function createMockFastCommandPrompt( - commandName: string, - series?: string, - basePath = '' -): FastCommandPrompt { - const content = 'Run something' - return { - type: PromptKind.FastCommand, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', basePath), - markdownContents: [], - yamlFrontMatter: {description: 'Fast command', namingCase: NamingCaseKind.KebabCase}, - ...series != null && {series}, - commandName - } as FastCommandPrompt -} - -function createMockSkillPrompt( - name: string, - content = '# Skill', - basePath = '', - options?: {childDocs?: {relativePath: string, content: unknown}[], resources?: {relativePath: string, content: string, encoding: 'text' | 'base64'}[]} -): SkillPrompt { - return { - yamlFrontMatter: {name, description: 'A skill', namingCase: NamingCaseKind.KebabCase}, - dir: createMockRelativePath(name, basePath), - content, - length: content.length, - type: PromptKind.Skill, - filePathKind: FilePathKind.Relative, - markdownContents: [], - ...options - } as unknown as SkillPrompt -} - -class TestableWindsurfOutputPlugin extends WindsurfOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -describe('windsurf output plugin', () => { - let tempDir: string, plugin: TestableWindsurfOutputPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-test-')) - plugin = new TestableWindsurfOutputPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - if (tempDir != null && fs.existsSync(tempDir)) { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch { - } // ignore cleanup errors - } - }) - - describe('constructor', () => { - it('should have correct plugin name', () => expect(plugin.name).toBe('WindsurfOutputPlugin')) - - it('should depend on AgentsOutputPlugin', () => expect(plugin.dependsOn).toContain('AgentsOutputPlugin')) - }) - - describe('registerGlobalOutputDirs', () => { - it('should return empty when no fastCommands and no skills', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(0) - }) - - it('should register global_workflows dir when fastCommands exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [createMockFastCommandPrompt('compile', void 0, tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(1) - expect(results[0]?.path).toBe('global_workflows') - expect(results[0]?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'global_workflows')) - }) - - it('should register skills/ dir when skills exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('custom-skill', '# Skill', tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(1) - expect(results[0]?.path).toBe(path.join('skills', 'custom-skill')) - expect(results[0]?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'skills', 'custom-skill')) - }) - - it('should register both workflows and skills dirs when both exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('skill-a', '# Skill', tempDir)], - fastCommands: [createMockFastCommandPrompt('compile', void 0, tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(2) - const paths = results.map(r => r.path) - expect(paths).toContain('global_workflows') - expect(paths).toContain(path.join('skills', 'skill-a')) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should return empty when no fastCommands and no skills', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results).toHaveLength(0) - }) - - it('should register workflow files under global_workflows/ when fastCommands exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [ - createMockFastCommandPrompt('compile', 'build', tempDir), - createMockFastCommandPrompt('test', void 0, tempDir) - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.length).toBeGreaterThanOrEqual(2) - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('global_workflows', 'build-compile.md')) - expect(paths).toContain(path.join('global_workflows', 'test.md')) - const compileEntry = results.find(r => r.path.includes('build-compile')) - expect(compileEntry?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'global_workflows', 'build-compile.md')) - }) - - it('should register skill files under skills//SKILL.md when skills exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('my-skill', '# Skill', tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.some(r => r.path === path.join('skills', 'my-skill', 'SKILL.md'))).toBe(true) - }) - - it('should register childDocs when skills have them', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('my-skill', '# Skill', tempDir, { - childDocs: [{relativePath: 'doc.cn.mdx', content: '# Child Doc'}] - }) - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.some(r => r.path === path.join('skills', 'my-skill', 'doc.cn.md'))).toBe(true) - }) - - it('should register resources when skills have them', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('my-skill', '# Skill', tempDir, { - resources: [{relativePath: 'resource.json', content: '{}', encoding: 'text'}] - }) - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.some(r => r.path === path.join('skills', 'my-skill', 'resource.json'))).toBe(true) - }) - }) - - describe('canWrite', () => { - it('should return true when skills exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [{yamlFrontMatter: {name: 's'}, dir: createMockRelativePath('s', tempDir)}] - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return false when no skills and no fastCommands and no globalMemory', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [], - globalMemory: null - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(false) - }) - - it('should return true when only fastCommands exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [createMockFastCommandPrompt('lint', void 0, tempDir)], - globalMemory: null - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return true when only globalMemory exists', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [], - globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return true when .codeiumignore exists in aiAgentIgnoreConfigFiles', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [], - globalMemory: null, - rules: [], - aiAgentIgnoreConfigFiles: [{fileName: '.codeiumignore', content: 'node_modules/'}] - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return false when only .codeignore (wrong name) exists in aiAgentIgnoreConfigFiles', async () => { // @see https://docs.windsurf.com/context-awareness/windsurf-ignore#windsurf-ignore - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [], - globalMemory: null, - rules: [], - aiAgentIgnoreConfigFiles: [{fileName: '.codeignore', content: 'node_modules/'}] - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(false) - }) - }) - - describe('writeGlobalOutputs', () => { - it('should write global memory to ~/.codeium/windsurf/memories/global_rules.md', async () => { - const globalContent = '# Global Rules\n\nAlways apply these rules.' - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: createMockGlobalMemoryPrompt(globalContent, tempDir), - skills: [], - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files.length).toBeGreaterThanOrEqual(1) - expect(results.files[0]?.success).toBe(true) - - const memoryPath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'global_rules.md') - expect(fs.existsSync(memoryPath)).toBe(true) - const content = fs.readFileSync(memoryPath, 'utf8') - expect(content).toContain(globalContent) - }) - - it('should write fast command files to ~/.codeium/windsurf/global_workflows/', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [ - createMockFastCommandPrompt('compile', 'build', tempDir), - createMockFastCommandPrompt('test', void 0, tempDir) - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files).toHaveLength(2) - - const workflowsDir = path.join(tempDir, '.codeium', 'windsurf', 'global_workflows') - expect(fs.existsSync(workflowsDir)).toBe(true) - - const buildCompilePath = path.join(workflowsDir, 'build-compile.md') - const testPath = path.join(workflowsDir, 'test.md') - expect(fs.existsSync(buildCompilePath)).toBe(true) - expect(fs.existsSync(testPath)).toBe(true) - - const buildCompileContent = fs.readFileSync(buildCompilePath, 'utf8') - expect(buildCompileContent).toContain('Run something') - }) - - it('should write skill to ~/.codeium/windsurf/skills//SKILL.md', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('my-skill', '# My Skill Content', tempDir)], - globalMemory: null, - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files.length).toBeGreaterThanOrEqual(1) - expect(results.files.every(f => f.success)).toBe(true) - - const skillPath = path.join(tempDir, '.codeium', 'windsurf', 'skills', 'my-skill', 'SKILL.md') - expect(fs.existsSync(skillPath)).toBe(true) - const content = fs.readFileSync(skillPath, 'utf8') - expect(content).toContain('name: my-skill') - expect(content).toContain('# My Skill Content') - }) - - it('should write childDocs when skills have them', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('my-skill', '# Skill', tempDir, { - childDocs: [{relativePath: 'guide.cn.mdx', content: '# Guide Content'}] - }) - ], - globalMemory: null, - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - await plugin.writeGlobalOutputs(ctx) - - const childDocPath = path.join(tempDir, '.codeium', 'windsurf', 'skills', 'my-skill', 'guide.cn.md') - expect(fs.existsSync(childDocPath)).toBe(true) - const content = fs.readFileSync(childDocPath, 'utf8') - expect(content).toContain('# Guide Content') - }) - - it('should write resources when skills have them', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('my-skill', '# Skill', tempDir, { - resources: [{relativePath: 'schema.json', content: '{"type": "object"}', encoding: 'text'}] - }) - ], - globalMemory: null, - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - await plugin.writeGlobalOutputs(ctx) - - const resourcePath = path.join(tempDir, '.codeium', 'windsurf', 'skills', 'my-skill', 'schema.json') - expect(fs.existsSync(resourcePath)).toBe(true) - const content = fs.readFileSync(resourcePath, 'utf8') - expect(content).toContain('{"type": "object"}') - }) - - it('should not write files on dryRun', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir), - skills: [], - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: true - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files.length).toBeGreaterThanOrEqual(1) - expect(results.files[0]?.success).toBe(true) - - const memoryPath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'global_rules.md') - expect(fs.existsSync(memoryPath)).toBe(false) - }) - - it('should write global rule files with trigger/globs frontmatter', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [], - rules: [ - createMockRulePrompt('test', 'glob', ['src/**/*.ts', '**/*.tsx'], 'global') - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files).toHaveLength(1) - - const rulePath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'rule-test-glob.md') - expect(fs.existsSync(rulePath)).toBe(true) - - const content = fs.readFileSync(rulePath, 'utf8') - expect(content).toContain('trigger: glob') - expect(content).toContain('globs: src/**/*.ts, **/*.tsx') - expect(content).not.toContain('globs: "src/**/*.ts, **/*.tsx"') - expect(content).toContain('Follow this rule.') - }) - }) - - describe('writeProjectOutputs', () => { - it('should return empty results when no project rules', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeProjectOutputs(ctx) - expect(results.files).toHaveLength(0) - expect(results.dirs).toHaveLength(0) - }) - - it('should write .codeiumignore to project directories', async () => { - const projectDir = path.join(tempDir, 'my-project') - fs.mkdirSync(projectDir, {recursive: true}) - - const ctx = { - collectedInputContext: { - workspace: { - projects: [ - { - name: 'my-project', - dirFromWorkspacePath: createMockRelativePath('my-project', tempDir) - } - ], - directory: createMockRelativePath('.', tempDir) - }, - rules: [], - aiAgentIgnoreConfigFiles: [{fileName: '.codeiumignore', content: 'node_modules/\n.env\ndist/'}] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeProjectOutputs(ctx) - - const ignorePath = path.join(tempDir, 'my-project', '.codeiumignore') - expect(fs.existsSync(ignorePath)).toBe(true) - const content = fs.readFileSync(ignorePath, 'utf8') - expect(content).toContain('node_modules/') - expect(results.files.some(f => f.success)).toBe(true) - }) - - it('should not write .codeignore (wrong name) to project directories', async () => { - const projectDir = path.join(tempDir, 'my-project') - fs.mkdirSync(projectDir, {recursive: true}) - - const ctx = { - collectedInputContext: { - workspace: { - projects: [ - { - name: 'my-project', - dirFromWorkspacePath: createMockRelativePath('my-project', tempDir) - } - ], - directory: createMockRelativePath('.', tempDir) - }, - rules: [], - aiAgentIgnoreConfigFiles: [{fileName: '.codeignore', content: 'node_modules/'}] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - await plugin.writeProjectOutputs(ctx) - - const wrongIgnorePath = path.join(tempDir, 'my-project', '.codeignore') - const correctIgnorePath = path.join(tempDir, 'my-project', '.codeiumignore') - expect(fs.existsSync(wrongIgnorePath)).toBe(false) - expect(fs.existsSync(correctIgnorePath)).toBe(false) - }) - - it('should write project rules and apply seriName include filter from projectConfig', async () => { - const ctx = { - collectedInputContext: { - workspace: { - projects: [ - { - name: 'proj1', - dirFromWorkspacePath: createMockRelativePath('proj1', tempDir), - projectConfig: {rules: {includeSeries: ['uniapp']}} - } - ], - directory: createMockRelativePath('.', tempDir) - }, - rules: [ - createMockRulePrompt('test', 'uniapp-only', ['src/**/*.vue'], 'project', 'uniapp'), - createMockRulePrompt('test', 'vue-only', ['src/**/*.ts'], 'project', 'vue') - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeProjectOutputs(ctx) - const outputPaths = results.files.map(file => file.path.path.replaceAll('\\', '/')) - - expect(outputPaths.some(p => p.endsWith('rule-test-uniapp-only.md'))).toBe(true) - expect(outputPaths.some(p => p.endsWith('rule-test-vue-only.md'))).toBe(false) - - const includedRulePath = path.join(tempDir, 'proj1', '.windsurf', 'rules', 'rule-test-uniapp-only.md') - const excludedRulePath = path.join(tempDir, 'proj1', '.windsurf', 'rules', 'rule-test-vue-only.md') - - expect(fs.existsSync(includedRulePath)).toBe(true) - expect(fs.existsSync(excludedRulePath)).toBe(false) - - const includedRuleContent = fs.readFileSync(includedRulePath, 'utf8') - expect(includedRuleContent).toContain('trigger: glob') - expect(includedRuleContent).toContain('globs: src/**/*.vue') - }) - }) - - describe('clean support', () => { - it('should register global output dirs for cleanup', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('my-skill', '# Skill', tempDir)], - fastCommands: [createMockFastCommandPrompt('test', void 0, tempDir)] - } - } as unknown as OutputPluginContext - - const dirs = await plugin.registerGlobalOutputDirs(ctx) - expect(dirs.length).toBe(2) - - const files = await plugin.registerGlobalOutputFiles(ctx) - expect(files.length).toBeGreaterThanOrEqual(2) - }) - }) -}) diff --git a/packages/plugin-windsurf/src/WindsurfOutputPlugin.ts b/packages/plugin-windsurf/src/WindsurfOutputPlugin.ts deleted file mode 100644 index 95d82f07..00000000 --- a/packages/plugin-windsurf/src/WindsurfOutputPlugin.ts +++ /dev/null @@ -1,388 +0,0 @@ -import type { - FastCommandPrompt, - OutputPluginContext, - OutputWriteContext, - RulePrompt, - SkillPrompt, - WriteResult, - WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' -import {Buffer} from 'node:buffer' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared/utils' -import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' - -const CODEIUM_WINDSURF_DIR = '.codeium/windsurf' -const WORKFLOWS_SUBDIR = 'global_workflows' -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-' - -export class WindsurfOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('WindsurfOutputPlugin', { - globalConfigDir: CODEIUM_WINDSURF_DIR, - outputFileName: '', - dependsOn: [PLUGIN_NAMES.AgentsOutput], - indexignore: '.codeiumignore' - }) - } - - async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {fastCommands, skills, rules} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - if (filteredCommands.length > 0) { - const workflowsDir = this.getGlobalWorkflowsDir() - results.push({pathKind: FilePathKind.Relative, path: WORKFLOWS_SUBDIR, basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => WORKFLOWS_SUBDIR, getAbsolutePath: () => workflowsDir}) - } - } - - if (skills != null && skills.length > 0) { - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter.name - const skillPath = path.join(this.getCodeiumWindsurfDir(), SKILLS_SUBDIR, skillName) - results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName), basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => skillName, getAbsolutePath: () => skillPath}) - } - } - - const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === '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 - } - - async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {skills, fastCommands} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - const workflowsDir = this.getGlobalWorkflowsDir() - const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) - for (const cmd of filteredCommands) { - const fileName = this.transformFastCommandName(cmd, transformOptions) - const fullPath = path.join(workflowsDir, fileName) - results.push({pathKind: FilePathKind.Relative, path: path.join(WORKFLOWS_SUBDIR, fileName), basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => WORKFLOWS_SUBDIR, getAbsolutePath: () => fullPath}) - } - } - - const globalRules = ctx.collectedInputContext.rules?.filter(r => this.normalizeRuleScope(r) === '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}) - } - } - - const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] - if (filteredSkills.length === 0) return results - - const skillsDir = this.getSkillsDir() - const codeiumDir = this.getCodeiumWindsurfDir() - for (const skill of filteredSkills) { - const skillName = skill.yamlFrontMatter.name - const skillDir = path.join(skillsDir, skillName) - results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, SKILL_FILE_NAME), basePath: codeiumDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, SKILL_FILE_NAME)}) - - if (skill.childDocs != null) { - for (const childDoc of skill.childDocs) { - const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') - results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, outputRelativePath), basePath: codeiumDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, outputRelativePath)}) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) results.push({pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, resource.relativePath), basePath: codeiumDir, getDirectoryName: () => skillName, getAbsolutePath: () => path.join(skillDir, resource.relativePath)}) - } - } - return results - } - - async canWrite(ctx: OutputWriteContext): Promise { - 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 === '.codeiumignore') ?? false - - 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, rules} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const fileResults: WriteResult[] = [] - const dirResults: WriteResult[] = [] - - if (globalMemory != null) fileResults.push(await this.writeGlobalMemory(ctx, globalMemory.content as string)) - - if (skills != null && skills.length > 0) { - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - const skillsDir = this.getSkillsDir() - for (const skill of filteredSkills) fileResults.push(...await this.writeGlobalSkill(ctx, skillsDir, skill)) - } - - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - const workflowsDir = this.getGlobalWorkflowsDir() - for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalWorkflow(ctx, workflowsDir, cmd)) - } - - const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global') - if (globalRules == null || globalRules.length === 0) return {files: fileResults, dirs: dirResults} - - const memoriesDir = this.getGlobalMemoriesDir() - for (const rule of globalRules) fileResults.push(await this.writeRuleFile(ctx, memoriesDir, rule, this.getCodeiumWindsurfDir(), MEMORIES_SUBDIR)) - return {files: fileResults, dirs: dirResults} - } - - async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { - const results: RelativePath[] = [] - const {workspace, rules} = ctx.collectedInputContext - if (rules == null || rules.length === 0) return results - - for (const project of workspace.projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - const projectRules = applySubSeriesGlobPrefix(filterRulesByProjectConfig(rules.filter(r => this.normalizeRuleScope(r) === 'project'), project.projectConfig), project.projectConfig) - if (projectRules.length === 0) 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 - - if (rules != null && rules.length > 0) { - for (const project of workspace.projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - const projectRules = applySubSeriesGlobPrefix(filterRulesByProjectConfig(rules.filter(r => this.normalizeRuleScope(r) === 'project'), project.projectConfig), project.projectConfig) - 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 - - if (rules != null && rules.length > 0) { - for (const project of workspace.projects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue - const projectRules = applySubSeriesGlobPrefix(filterRulesByProjectConfig(rules.filter(r => this.normalizeRuleScope(r) === 'project'), project.projectConfig), project.projectConfig) - if (projectRules.length === 0) continue - const rulesDir = path.join(projectDir.basePath, projectDir.path, WINDSURF_RULES_DIR, WINDSURF_RULES_SUBDIR) - for (const rule of projectRules) fileResults.push(await this.writeRuleFile(ctx, rulesDir, rule, projectDir.basePath, path.join(projectDir.path, WINDSURF_RULES_DIR, WINDSURF_RULES_SUBDIR))) - } - } - - fileResults.push(...await this.writeProjectIgnoreFiles(ctx)) - return {files: fileResults, dirs: []} - } - - private getSkillsDir(): string { return path.join(this.getCodeiumWindsurfDir(), SKILLS_SUBDIR) } - private getCodeiumWindsurfDir(): string { return path.join(this.getHomeDir(), CODEIUM_WINDSURF_DIR) } - private getGlobalMemoriesDir(): string { return path.join(this.getCodeiumWindsurfDir(), MEMORIES_SUBDIR) } - private getGlobalWorkflowsDir(): string { return path.join(this.getCodeiumWindsurfDir(), WORKFLOWS_SUBDIR) } - - private async writeGlobalMemory(ctx: OutputWriteContext, content: string): Promise { - const memoriesDir = this.getGlobalMemoriesDir() - const fullPath = path.join(memoriesDir, GLOBAL_MEMORY_FILE) - const codeiumDir = this.getCodeiumWindsurfDir() - const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(MEMORIES_SUBDIR, GLOBAL_MEMORY_FILE), basePath: codeiumDir, getDirectoryName: () => MEMORIES_SUBDIR, getAbsolutePath: () => fullPath} - - if (ctx.dryRun === true) { this.log.trace({action: 'dryRun', type: 'globalMemory', path: fullPath}); return {path: relativePath, success: true, skipped: false} } - - try { - this.ensureDirectory(memoriesDir) - this.writeFileSync(fullPath, content) - this.log.trace({action: 'write', type: 'globalMemory', path: fullPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalMemory', path: fullPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private async writeGlobalWorkflow(ctx: OutputWriteContext, workflowsDir: string, cmd: FastCommandPrompt): Promise { - const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) - const fileName = this.transformFastCommandName(cmd, transformOptions) - const fullPath = path.join(workflowsDir, fileName) - const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(WORKFLOWS_SUBDIR, fileName), basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => WORKFLOWS_SUBDIR, getAbsolutePath: () => fullPath} - const content = this.buildMarkdownContentWithRaw(cmd.content, cmd.yamlFrontMatter, cmd.rawFrontMatter) - - if (ctx.dryRun === true) { this.log.trace({action: 'dryRun', type: 'globalWorkflow', path: fullPath}); return {path: relativePath, success: true, skipped: false} } - - try { - this.ensureDirectory(workflowsDir) - fs.writeFileSync(fullPath, content) - this.log.trace({action: 'write', type: 'globalWorkflow', path: fullPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalWorkflow', path: fullPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private async writeGlobalSkill(ctx: OutputWriteContext, skillsDir: string, skill: SkillPrompt): Promise { - const results: WriteResult[] = [] - const skillName = skill.yamlFrontMatter.name - const skillDir = path.join(skillsDir, skillName) - const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) - const codeiumDir = this.getCodeiumWindsurfDir() - const skillRelativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, SKILL_FILE_NAME), basePath: codeiumDir, getDirectoryName: () => skillName, getAbsolutePath: () => skillFilePath} - - const frontMatterData = this.buildSkillFrontMatter(skill) - const skillContent = buildMarkdownWithFrontMatter(frontMatterData, skill.content as string) - - if (ctx.dryRun === true) { - this.log.trace({action: 'dryRun', type: 'skill', path: skillFilePath}) - results.push({path: skillRelativePath, success: true, skipped: false}) - } else { - try { - this.ensureDirectory(skillDir) - this.writeFileSync(skillFilePath, skillContent) - this.log.trace({action: 'write', type: 'skill', path: skillFilePath}) - results.push({path: skillRelativePath, success: true}) - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'skill', path: skillFilePath, error: errMsg}) - results.push({path: skillRelativePath, success: false, error: error as Error}) - } - } - - if (skill.childDocs != null) { - for (const childDoc of skill.childDocs) results.push(await this.writeSkillChildDoc(ctx, childDoc, skillDir, skillName, codeiumDir)) - } - - if (skill.resources != null) { - for (const resource of skill.resources) results.push(await this.writeSkillResource(ctx, resource, skillDir, skillName, codeiumDir)) - } - - return results - } - - private buildSkillFrontMatter(skill: SkillPrompt): Record { - const fm = skill.yamlFrontMatter - return {name: fm.name, description: fm.description, ...fm.displayName != null && {displayName: fm.displayName}, ...fm.keywords != null && fm.keywords.length > 0 && {keywords: fm.keywords}, ...fm.author != null && {author: fm.author}, ...fm.version != null && {version: fm.version}, ...fm.allowTools != null && fm.allowTools.length > 0 && {allowTools: fm.allowTools}} - } - - private async writeSkillChildDoc(ctx: OutputWriteContext, childDoc: {relativePath: string, content: unknown}, skillDir: string, skillName: string, baseDir: string): Promise { - const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') - const childDocPath = path.join(skillDir, outputRelativePath) - const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, outputRelativePath), basePath: baseDir, getDirectoryName: () => skillName, getAbsolutePath: () => childDocPath} - const content = childDoc.content as string - - if (ctx.dryRun === true) { this.log.trace({action: 'dryRun', type: 'childDoc', path: childDocPath}); return {path: relativePath, success: true, skipped: false} } - - try { - this.ensureDirectory(path.dirname(childDocPath)) - this.writeFileSync(childDocPath, content) - this.log.trace({action: 'write', type: 'childDoc', path: childDocPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'childDoc', path: childDocPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private async writeSkillResource(ctx: OutputWriteContext, resource: {relativePath: string, content: string, encoding: 'text' | 'base64'}, skillDir: string, skillName: string, baseDir: string): Promise { - const resourcePath = path.join(skillDir, resource.relativePath) - const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(SKILLS_SUBDIR, skillName, resource.relativePath), basePath: baseDir, getDirectoryName: () => skillName, getAbsolutePath: () => resourcePath} - - if (ctx.dryRun === true) { this.log.trace({action: 'dryRun', type: 'resource', path: resourcePath}); return {path: relativePath, success: true, skipped: false} } - - try { - this.ensureDirectory(path.dirname(resourcePath)) - if (resource.encoding === 'base64') this.writeFileSyncBuffer(resourcePath, Buffer.from(resource.content, 'base64')) - else this.writeFileSync(resourcePath, resource.content) - this.log.trace({action: 'write', type: 'resource', path: resourcePath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'resource', path: resourcePath, error: errMsg}) - 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 fmData: Record = {trigger: 'glob', globs: rule.globs.length > 0 ? rule.globs.join(', ') : ''} - const raw = buildMarkdownWithFrontMatter(fmData, rule.content) - const lines = raw.split('\n') - return lines.map(line => { - const match = /^(\s*globs:\s*)(['"])(.*)\2\s*$/.exec(line) - if (match == null) return line - const prefix = match[1] ?? 'globs: ' - const value = match[3] ?? '' - if (value.trim().length === 0) return line - return `${prefix}${value}` - }).join('\n') - } - - 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/packages/plugin-windsurf/src/index.ts b/packages/plugin-windsurf/src/index.ts deleted file mode 100644 index e749bd3d..00000000 --- a/packages/plugin-windsurf/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - WindsurfOutputPlugin -} from './WindsurfOutputPlugin' diff --git a/packages/plugin-windsurf/tsconfig.eslint.json b/packages/plugin-windsurf/tsconfig.eslint.json deleted file mode 100644 index 88bad67b..00000000 --- a/packages/plugin-windsurf/tsconfig.eslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist", "coverage"] -} diff --git a/packages/plugin-windsurf/tsconfig.json b/packages/plugin-windsurf/tsconfig.json deleted file mode 100644 index 9b2710e7..00000000 --- a/packages/plugin-windsurf/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "noUncheckedSideEffectImports": true, - "incremental": true, - "composite": false, - "target": "ESNext", - "lib": ["ESNext"], - "moduleDetection": "force", - "useDefineForClassFields": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Bundler", - "paths": { "@/*": ["./src/*"] }, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "strict": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "alwaysStrict": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useUnknownInCatchVariables": true, - "declaration": true, - "declarationMap": true, - "importHelpers": true, - "newLine": "lf", - "noEmit": true, - "noEmitHelpers": false, - "removeComments": false, - "sourceMap": true, - "stripInternal": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src/**/*", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-windsurf/tsconfig.lib.json b/packages/plugin-windsurf/tsconfig.lib.json deleted file mode 100644 index e2395022..00000000 --- a/packages/plugin-windsurf/tsconfig.lib.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "composite": true, "rootDir": "./src", "noEmit": false, "outDir": "../dist", "skipLibCheck": true }, - "include": ["src/**/*", "env.d.ts"], - "exclude": ["../node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/packages/plugin-windsurf/tsconfig.test.json b/packages/plugin-windsurf/tsconfig.test.json deleted file mode 100644 index c59a650c..00000000 --- a/packages/plugin-windsurf/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "vitest.config.ts", "vite.config.ts", "env.d.ts"], - "exclude": ["../node_modules", "dist"] -} diff --git a/packages/plugin-windsurf/tsdown.config.ts b/packages/plugin-windsurf/tsdown.config.ts deleted file mode 100644 index 63062043..00000000 --- a/packages/plugin-windsurf/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {resolve} from 'node:path' -import {defineConfig} from 'tsdown' - -export default defineConfig([ - { - entry: ['./src/index.ts', '!**/*.{spec,test}.*'], - platform: 'node', - sourcemap: false, - unbundle: false, - inlineOnly: false, - alias: {'@': resolve('src')}, - format: ['esm'], - minify: false, - dts: {sourcemap: false} - } -]) diff --git a/packages/plugin-windsurf/vite.config.ts b/packages/plugin-windsurf/vite.config.ts deleted file mode 100644 index 8e7dba8b..00000000 --- a/packages/plugin-windsurf/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {fileURLToPath, URL} from 'node:url' -import {defineConfig} from 'vite' - -export default defineConfig({ - resolve: { - alias: {'@': fileURLToPath(new URL('./src', import.meta.url))} - } -}) diff --git a/packages/plugin-windsurf/vitest.config.ts b/packages/plugin-windsurf/vitest.config.ts deleted file mode 100644 index e7fbb710..00000000 --- a/packages/plugin-windsurf/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {fileURLToPath} from 'node:url' -import {configDefaults, defineConfig, mergeConfig} from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'node', - exclude: [...configDefaults.exclude, 'e2e/*'], - root: fileURLToPath(new URL('./', import.meta.url)), - typecheck: {enabled: true, tsconfig: './tsconfig.test.json'}, - testTimeout: 30000, - onConsoleLog: () => false, - passWithNoTests: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.property.test.ts'] - } - } - }) -) From 915d4a75852bd7ab528643b4496ca3af151e08ea Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 00:35:33 +0800 Subject: [PATCH 08/10] =?UTF-8?q?chore:=20=E4=BB=8E=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E9=85=8D=E7=BD=AE=E4=B8=AD=E7=A7=BB=E9=99=A4packages?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不再需要单独管理packages目录下的包,因此从pnpm工作区配置中移除该条目 --- pnpm-workspace.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eb31ff91..d8d1531c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,6 @@ packages: - cli - cli/npm/* - - packages/* - libraries/* - libraries/logger/npm/* - libraries/md-compiler/npm/* From cae0e2620231cb1bfaa4555120a6cd61fd2d9dee Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 00:37:39 +0800 Subject: [PATCH 09/10] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0package.json?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E7=89=88=E6=9C=AC=E5=8F=B7=E8=87=B32026.1030?= =?UTF-8?q?2.10037?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/npm/darwin-arm64/package.json | 2 +- cli/npm/darwin-x64/package.json | 2 +- cli/npm/linux-arm64-gnu/package.json | 2 +- cli/npm/linux-x64-gnu/package.json | 2 +- cli/npm/win32-x64-msvc/package.json | 2 +- cli/package.json | 2 +- doc/package.json | 2 +- gui/package.json | 2 +- gui/src-tauri/Cargo.toml | 2 +- gui/src-tauri/tauri.conf.json | 2 +- libraries/config/package.json | 2 +- libraries/init-bundle/package.json | 2 +- libraries/init-bundle/public/public/tnmsc.example.json | 2 +- libraries/input-plugins/package.json | 2 +- libraries/logger/package.json | 2 +- libraries/md-compiler/package.json | 2 +- package.json | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index 4742c04c..ddf9caad 100644 --- a/cli/npm/darwin-arm64/package.json +++ b/cli/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-arm64", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "os": [ "darwin" ], diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index fe2edac7..0308e7fc 100644 --- a/cli/npm/darwin-x64/package.json +++ b/cli/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-x64", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "os": [ "darwin" ], diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index 5c10a30f..9b7bb142 100644 --- a/cli/npm/linux-arm64-gnu/package.json +++ b/cli/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-arm64-gnu", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "os": [ "linux" ], diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 208e4cf1..3b7a52b9 100644 --- a/cli/npm/linux-x64-gnu/package.json +++ b/cli/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-x64-gnu", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "os": [ "linux" ], diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index 436f6ec9..f296a87c 100644 --- a/cli/npm/win32-x64-msvc/package.json +++ b/cli/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-win32-x64-msvc", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "os": [ "win32" ], diff --git a/cli/package.json b/cli/package.json index 12857656..81e90ded 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "description": "TrueNine Memory Synchronization CLI", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/doc/package.json b/doc/package.json index f25f7984..d8dfad86 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-docs", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "private": true, "description": "Documentation site for @truenine/memory-sync, built with Next.js 16 and MDX.", "engines": { diff --git a/gui/package.json b/gui/package.json index 64347e30..9da7b6bd 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "private": true, "engines": { "node": ">=25.2.1", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 41902044..6b792606 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memory-sync-gui" -version = "2026.10224.10619" +version = "2026.10302.10037" description = "Memory Sync desktop GUI application" authors.workspace = true edition.workspace = true diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 7c57a2c3..a28261d8 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.10224.10619", + "version": "2026.10302.10037", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/libraries/config/package.json b/libraries/config/package.json index cb46744b..069c7917 100644 --- a/libraries/config/package.json +++ b/libraries/config/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/config", "type": "module", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "private": true, "description": "Rust-powered configuration loader for Node.js", "license": "AGPL-3.0-only", diff --git a/libraries/init-bundle/package.json b/libraries/init-bundle/package.json index 7cad7f79..12c2c339 100644 --- a/libraries/init-bundle/package.json +++ b/libraries/init-bundle/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/init-bundle", "type": "module", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "private": true, "description": "Rust-powered embedded file templates for tnmsc init command", "license": "AGPL-3.0-only", diff --git a/libraries/init-bundle/public/public/tnmsc.example.json b/libraries/init-bundle/public/public/tnmsc.example.json index 42a69e4c..8ecaebd5 100644 --- a/libraries/init-bundle/public/public/tnmsc.example.json +++ b/libraries/init-bundle/public/public/tnmsc.example.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/@truenine/memory-sync-cli/dist/tnmsc.schema.json", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "workspaceDir": "~/project", "shadowSourceProject": { "name": "tnmsc-shadow", diff --git a/libraries/input-plugins/package.json b/libraries/input-plugins/package.json index 409f767a..80a5320b 100644 --- a/libraries/input-plugins/package.json +++ b/libraries/input-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/input-plugins", "type": "module", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "private": true, "description": "Rust-powered input plugins for tnmsc pipeline (stub)", "license": "AGPL-3.0-only", diff --git a/libraries/logger/package.json b/libraries/logger/package.json index 998e3612..9bb361a9 100644 --- a/libraries/logger/package.json +++ b/libraries/logger/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/logger", "type": "module", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "private": true, "description": "Rust-powered structured logger for Node.js with pure-TS fallback", "license": "AGPL-3.0-only", diff --git a/libraries/md-compiler/package.json b/libraries/md-compiler/package.json index ff35c08e..4b8185dc 100644 --- a/libraries/md-compiler/package.json +++ b/libraries/md-compiler/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/md-compiler", "type": "module", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "private": true, "description": "Rust-powered MDX→Markdown compiler for Node.js with pure-TS fallback", "license": "AGPL-3.0-only", diff --git a/package.json b/package.json index e11f51ac..962bc478 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync", - "version": "2026.10224.10619", + "version": "2026.10302.10037", "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": [ From 9cc08de3e458695f369cf85b868d63e4ae61ca4f Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 00:39:29 +0800 Subject: [PATCH 10/10] =?UTF-8?q?build:=20=E6=9B=B4=E6=96=B0=20memory-sync?= =?UTF-8?q?-gui=20=E7=9A=84=E7=89=88=E6=9C=AC=E5=8F=B7=E8=87=B3=202026.103?= =?UTF-8?q?02.10037?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 296c4779..b3fbe2cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10224.10619" +version = "2026.10302.10037" dependencies = [ "dirs", "proptest",