From 11d4f8de161b1b4e3ae521ad0f4d68f96db6c1b1 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 02:33:04 +0800 Subject: [PATCH 01/30] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4init-bundle?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8F=8A=E7=9B=B8=E5=85=B3=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清理不再使用的init-bundle模块,包括其TypeScript和Rust实现、配置文件、测试用例及所有相关依赖项 更新构建脚本和文档以反映此变更 --- Cargo.lock | 12 - Cargo.toml | 2 - cli/Cargo.toml | 1 - cli/env.d.ts | 1 - cli/package.json | 3 +- cli/src/ShadowSourceProject.ts | 59 +- cli/src/constants.ts | 6 +- .../GitIgnoreInputPlugin.test.ts | 4 +- .../GitIgnoreInputPlugin.ts | 12 +- cli/src/plugins/plugin-shared/constants.ts | 6 +- cli/tsdown.config.ts | 4 +- cli/vite.config.ts | 3 +- gui/package.json | 2 +- libraries/init-bundle/Cargo.toml | 25 - libraries/init-bundle/build.rs | 4 - libraries/init-bundle/eslint.config.ts | 26 - libraries/init-bundle/index.d.ts | 0 libraries/init-bundle/package.json | 52 - libraries/init-bundle/public/.editorconfig | 26 - libraries/init-bundle/public/.gitignore | 6 - libraries/init-bundle/public/.idea/.gitignore | 15 - .../public/.idea/codeStyles/Project.xml | 176 --- .../.idea/codeStyles/codeStyleConfig.xml | 5 - .../public/.vscode/extensions.json | 19 - .../init-bundle/public/.vscode/settings.json | 137 -- .../init-bundle/public/app/global.cn.mdx | 31 - libraries/init-bundle/public/public/exclude | 31 - libraries/init-bundle/public/public/gitignore | 33 - .../public/kiro_global_powers_registry.json | 318 ---- .../public/public/tnmsc.example.json | 43 - .../prompt-builder/child-memory-prompt.cn.mdx | 48 - .../global-memory-prompt.cn.mdx | 13 - .../prompt-builder/root-memory-prompt.cn.mdx | 63 - libraries/init-bundle/src/index.ts | 67 - libraries/init-bundle/src/lib.rs | 179 --- libraries/init-bundle/tsconfig.json | 68 - libraries/init-bundle/tsconfig.lib.json | 21 - libraries/init-bundle/tsdown.config.ts | 18 - libraries/input-plugins/Cargo.toml | 1 - libraries/input-plugins/package.json | 2 + pnpm-lock.yaml | 1384 +++++++++-------- pnpm-workspace.yaml | 17 +- scripts/build-native.ts | 2 +- scripts/copy-napi.ts | 3 +- 44 files changed, 749 insertions(+), 2199 deletions(-) delete mode 100644 libraries/init-bundle/Cargo.toml delete mode 100644 libraries/init-bundle/build.rs delete mode 100644 libraries/init-bundle/eslint.config.ts delete mode 100644 libraries/init-bundle/index.d.ts delete mode 100644 libraries/init-bundle/package.json delete mode 100644 libraries/init-bundle/public/.editorconfig delete mode 100644 libraries/init-bundle/public/.gitignore delete mode 100644 libraries/init-bundle/public/.idea/.gitignore delete mode 100644 libraries/init-bundle/public/.idea/codeStyles/Project.xml delete mode 100644 libraries/init-bundle/public/.idea/codeStyles/codeStyleConfig.xml delete mode 100644 libraries/init-bundle/public/.vscode/extensions.json delete mode 100644 libraries/init-bundle/public/.vscode/settings.json delete mode 100644 libraries/init-bundle/public/app/global.cn.mdx delete mode 100644 libraries/init-bundle/public/public/exclude delete mode 100644 libraries/init-bundle/public/public/gitignore delete mode 100644 libraries/init-bundle/public/public/kiro_global_powers_registry.json delete mode 100644 libraries/init-bundle/public/public/tnmsc.example.json delete mode 100644 libraries/init-bundle/public/src/skills/prompt-builder/child-memory-prompt.cn.mdx delete mode 100644 libraries/init-bundle/public/src/skills/prompt-builder/global-memory-prompt.cn.mdx delete mode 100644 libraries/init-bundle/public/src/skills/prompt-builder/root-memory-prompt.cn.mdx delete mode 100644 libraries/init-bundle/src/index.ts delete mode 100644 libraries/init-bundle/src/lib.rs delete mode 100644 libraries/init-bundle/tsconfig.json delete mode 100644 libraries/init-bundle/tsconfig.lib.json delete mode 100644 libraries/init-bundle/tsdown.config.ts diff --git a/Cargo.lock b/Cargo.lock index b3fbe2cb..5f3943b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4450,7 +4450,6 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tnmsc-config", - "tnmsc-init-bundle", "tnmsc-input-plugins", "tnmsc-logger", "tnmsc-md-compiler", @@ -4471,16 +4470,6 @@ dependencies = [ "tnmsc-logger", ] -[[package]] -name = "tnmsc-init-bundle" -version = "2026.10222.0" -dependencies = [ - "napi", - "napi-build", - "napi-derive", - "serde_json", -] - [[package]] name = "tnmsc-input-plugins" version = "2026.10222.0" @@ -4494,7 +4483,6 @@ dependencies = [ "serde_json", "sha2", "tnmsc-config", - "tnmsc-init-bundle", "tnmsc-logger", "tnmsc-md-compiler", "tnmsc-plugin-shared", diff --git a/Cargo.toml b/Cargo.toml index 06308e73..9a221b19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ members = [ "libraries/config", "libraries/plugin-shared", "libraries/input-plugins", - "libraries/init-bundle", "gui/src-tauri", ] @@ -26,7 +25,6 @@ tnmsc-md-compiler = { path = "libraries/md-compiler" } tnmsc-config = { path = "libraries/config" } tnmsc-plugin-shared = { path = "libraries/plugin-shared" } tnmsc-input-plugins = { path = "libraries/input-plugins" } -tnmsc-init-bundle = { path = "libraries/init-bundle" } # Serialization serde = { version = "1", features = ["derive"] } diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 0b37033e..bfbf5362 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -25,7 +25,6 @@ tnmsc-md-compiler = { workspace = true } tnmsc-config = { workspace = true } tnmsc-plugin-shared = { workspace = true } tnmsc-input-plugins = { workspace = true } -tnmsc-init-bundle = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = "2" diff --git a/cli/env.d.ts b/cli/env.d.ts index 0acdf7e3..54f2842b 100644 --- a/cli/env.d.ts +++ b/cli/env.d.ts @@ -14,6 +14,5 @@ declare const __CLI_PACKAGE_NAME__: string /** * Kiro global powers registry JSON string injected at build time - * from init-bundle (public/kiro_global_powers_registry.json) */ declare const __KIRO_GLOBAL_POWERS_REGISTRY__: string diff --git a/cli/package.json b/cli/package.json index 81e90ded..0e1d349f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -62,7 +62,7 @@ "picocolors": "catalog:", "picomatch": "catalog:", "tsx": "4.21.0", - "vitest": "^4.0.18", + "vitest": "catalog:", "yaml": "2.8.2", "zod": "catalog:" }, @@ -74,7 +74,6 @@ "@truenine/memory-sync-cli-win32-x64-msvc": "workspace:*" }, "devDependencies": { - "@truenine/init-bundle": "workspace:*", "@truenine/logger": "workspace:*", "@truenine/md-compiler": "workspace:*", "@types/fs-extra": "catalog:", diff --git a/cli/src/ShadowSourceProject.ts b/cli/src/ShadowSourceProject.ts index d2286047..cf408b13 100644 --- a/cli/src/ShadowSourceProject.ts +++ b/cli/src/ShadowSourceProject.ts @@ -5,7 +5,6 @@ import type {ILogger} from '@truenine/plugin-shared' import * as fs from 'node:fs' import * as path from 'node:path' -import {bundles} from '@truenine/init-bundle' /** * Version control check result @@ -52,50 +51,25 @@ export interface GenerationResult { * Generation options */ export interface GenerationOptions { - /** Source directory to copy config files from (if exists) */ - readonly sourceDir?: string /** Logger instance */ readonly logger?: ILogger } -/** - * Helper to read file from source or return default content - */ -function getFileContent( - filePath: string, - basePath: string, - sourceDir: string | undefined, - defaultContent: string, - logger?: ILogger -): string { - if (sourceDir == null) return defaultContent - - const relativePath = path.relative(basePath, filePath) // Calculate relative path from base - const sourceFilePath = path.join(sourceDir, relativePath) - - if (!(fs.existsSync(sourceFilePath) && fs.statSync(sourceFilePath).isFile())) return defaultContent - - logger?.debug('copying from source', {path: sourceFilePath}) - return fs.readFileSync(sourceFilePath, 'utf8') -} - /** * Generate shadow source project directory structure - * Iterates through the flat bundles object to create directories and files - * If sourceDir is provided and contains config files, they will be copied instead of using defaults */ export function generateShadowSourceProject( rootPath: string, options: GenerationOptions = {} ): GenerationResult { - const {sourceDir, logger} = options + const {logger} = options const createdDirs: string[] = [] const createdFiles: string[] = [] const existedDirs: string[] = [] const existedFiles: string[] = [] - const createdDirsSet = new Set() // Track created directories to avoid duplicates + const createdDirsSet = new Set() - if (fs.existsSync(rootPath)) { // Ensure root directory exists + if (fs.existsSync(rootPath)) { existedDirs.push(rootPath) logger?.debug('directory exists', {path: rootPath}) } else { @@ -105,33 +79,6 @@ export function generateShadowSourceProject( logger?.info('created directory', {path: rootPath}) } - for (const bundleItem of Object.values(bundles)) { // Iterate through all bundles and create files - const relativePath = bundleItem.path - const fullPath = path.join(rootPath, relativePath) - const dir = path.dirname(fullPath) - - if (!fs.existsSync(dir)) { // Ensure parent directory exists - fs.mkdirSync(dir, {recursive: true}) - let currentDir = dir // Track all intermediate directories - while (currentDir !== rootPath && !createdDirsSet.has(currentDir)) { - createdDirsSet.add(currentDir) - createdDirs.push(currentDir) - logger?.info('created directory', {path: currentDir}) - currentDir = path.dirname(currentDir) - } - } - - if (fs.existsSync(fullPath)) { // Create or skip file - existedFiles.push(fullPath) - logger?.debug('file exists', {path: fullPath}) - } else { - const content = getFileContent(fullPath, rootPath, sourceDir, bundleItem.content, logger) - fs.writeFileSync(fullPath, content, 'utf8') - createdFiles.push(fullPath) - logger?.info('created file', {path: fullPath}) - } - } - return { success: true, rootPath, diff --git a/cli/src/constants.ts b/cli/src/constants.ts index 747cb185..55a3d23e 100644 --- a/cli/src/constants.ts +++ b/cli/src/constants.ts @@ -1,11 +1,9 @@ import type {UserConfigFile} from '@truenine/plugin-shared' -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 +type DefaultUserConfig = Readonly>> +export const DEFAULT_USER_CONFIG = {} as DefaultUserConfig diff --git a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts index f70fc2b4..6ead0522 100644 --- a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts +++ b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts @@ -59,8 +59,6 @@ describe('gitIgnoreInputPlugin', () => { 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({}) + expect(result).toEqual({}) }) }) diff --git a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts index ffb80a00..987841dd 100644 --- a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts +++ b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts @@ -1,23 +1,13 @@ 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} : {}) + super('GitIgnoreInputPlugin') } protected getFilePath(shadowProjectDir: string): string { diff --git a/cli/src/plugins/plugin-shared/constants.ts b/cli/src/plugins/plugin-shared/constants.ts index d7e089cb..fdd1df7c 100644 --- a/cli/src/plugins/plugin-shared/constants.ts +++ b/cli/src/plugins/plugin-shared/constants.ts @@ -1,11 +1,9 @@ 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 +type DefaultUserConfig = Readonly>> +export const DEFAULT_USER_CONFIG = {} as DefaultUserConfig diff --git a/cli/tsdown.config.ts b/cli/tsdown.config.ts index 69b9cd37..c38c3601 100644 --- a/cli/tsdown.config.ts +++ b/cli/tsdown.config.ts @@ -1,10 +1,9 @@ import {readFileSync} from 'node:fs' import {resolve} from 'node:path' -import {bundles} from '@truenine/init-bundle' import {defineConfig} from 'tsdown' 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 kiroGlobalPowersRegistry = '{"version":"1.0.0","powers":{},"repoSources":{}}' const pluginAliases: Record = { '@truenine/desk-paths': resolve('src/plugins/desk-paths/index.ts'), @@ -55,7 +54,6 @@ const noExternalDeps = [ '@truenine/logger', 'fast-glob', '@truenine/desk-paths', - '@truenine/init-bundle', '@truenine/md-compiler', ...Object.keys(pluginAliases) ] diff --git a/cli/vite.config.ts b/cli/vite.config.ts index 891d70bf..5d488a6c 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -1,11 +1,10 @@ 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' 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 kiroGlobalPowersRegistry = '{"version":"1.0.0","powers":{},"repoSources":{}}' const pluginAliases: Record = { '@truenine/desk-paths': resolve('src/plugins/desk-paths/index.ts'), diff --git a/gui/package.json b/gui/package.json index 9da7b6bd..4c7d4fbf 100644 --- a/gui/package.json +++ b/gui/package.json @@ -41,7 +41,7 @@ }, "devDependencies": { "@tailwindcss/vite": "catalog:", - "@tanstack/router-generator": "^1.162.2", + "@tanstack/router-generator": "^1.164.0", "@tauri-apps/cli": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/libraries/init-bundle/Cargo.toml b/libraries/init-bundle/Cargo.toml deleted file mode 100644 index 62321d86..00000000 --- a/libraries/init-bundle/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "tnmsc-init-bundle" -description = "Embedded file templates for tnmsc init command" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true - -[lib] -crate-type = ["rlib", "cdylib"] - -[features] -default = [] -napi = ["dep:napi", "dep:napi-derive"] - -[dependencies] -napi = { workspace = true, optional = true } -napi-derive = { workspace = true, optional = true } - -[dev-dependencies] -serde_json = { workspace = true } - -[build-dependencies] -napi-build = { workspace = true } diff --git a/libraries/init-bundle/build.rs b/libraries/init-bundle/build.rs deleted file mode 100644 index f2be9938..00000000 --- a/libraries/init-bundle/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -fn main() { - #[cfg(feature = "napi")] - napi_build::setup(); -} diff --git a/libraries/init-bundle/eslint.config.ts b/libraries/init-bundle/eslint.config.ts deleted file mode 100644 index d1de0a15..00000000 --- a/libraries/init-bundle/eslint.config.ts +++ /dev/null @@ -1,26 +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', - '**/*.toml', - '**/*.d.ts' - ] -}) - -export default config as unknown diff --git a/libraries/init-bundle/index.d.ts b/libraries/init-bundle/index.d.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/libraries/init-bundle/package.json b/libraries/init-bundle/package.json deleted file mode 100644 index 12c2c339..00000000 --- a/libraries/init-bundle/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "@truenine/init-bundle", - "type": "module", - "version": "2026.10302.10037", - "private": true, - "description": "Rust-powered embedded file templates for tnmsc init command", - "license": "AGPL-3.0-only", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "files": [ - "dist" - ], - "napi": { - "binaryName": "napi-init-bundle", - "targets": [ - "x86_64-pc-windows-msvc", - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - "aarch64-apple-darwin", - "x86_64-apple-darwin" - ] - }, - "scripts": { - "build": "tsdown", - "build:all": "run-s build:native build", - "build:native": "napi build --platform --release --output-dir dist -- --features napi", - "build:native:debug": "napi build --platform --output-dir dist -- --features napi", - "build:ts": "tsdown", - "check": "run-p typecheck lint", - "lint": "eslint --cache .", - "lintfix": "eslint --fix --cache .", - "prepublishOnly": "run-s build", - "test": "run-s test:rust test:ts", - "test:rust": "tsx ../../scripts/cargo-test.ts", - "test:ts": "vitest run --passWithNoTests", - "typecheck": "tsc --noEmit -p tsconfig.lib.json" - }, - "devDependencies": { - "@napi-rs/cli": "^3.5.1", - "npm-run-all2": "catalog:", - "tsdown": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/libraries/init-bundle/public/.editorconfig b/libraries/init-bundle/public/.editorconfig deleted file mode 100644 index 39e683a0..00000000 --- a/libraries/init-bundle/public/.editorconfig +++ /dev/null @@ -1,26 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -indent_size = 2 -tab_width = 2 -max_line_length = 160 -indent_style = space -insert_final_newline = true - -[*.md] -max_line_length = off - -[*.toon] -indent_size = 1 -tab_width = 1 - -[Makefile] -indent_style = tab - -[.gitmodules] -indent_style = tab - -[.gitconfig] -indent_style = tab diff --git a/libraries/init-bundle/public/.gitignore b/libraries/init-bundle/public/.gitignore deleted file mode 100644 index c1840ffa..00000000 --- a/libraries/init-bundle/public/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -logs/ -.google-devtools-mcp/ -nul -node_modules/ -pnpm-lock.yaml -.DS_Store diff --git a/libraries/init-bundle/public/.idea/.gitignore b/libraries/init-bundle/public/.idea/.gitignore deleted file mode 100644 index b9ad7d89..00000000 --- a/libraries/init-bundle/public/.idea/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -# 排除所有文件和目录 -* - -# 例外:保留这些文件 -!.gitignore -!codeStyles/ -!codeStyles/codeStyleConfig.xml -!codeStyles/Project.xml -!/sqldialects.xml -!/modules/ -!/modules/compose-server.iml -!/misc.xml -!/vcs.xml -!/*.iml -!/modules.xml diff --git a/libraries/init-bundle/public/.idea/codeStyles/Project.xml b/libraries/init-bundle/public/.idea/codeStyles/Project.xml deleted file mode 100644 index 6efa3d87..00000000 --- a/libraries/init-bundle/public/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,176 +0,0 @@ - - - - - diff --git a/libraries/init-bundle/public/.idea/codeStyles/codeStyleConfig.xml b/libraries/init-bundle/public/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index c5678b63..00000000 --- a/libraries/init-bundle/public/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/libraries/init-bundle/public/.vscode/extensions.json b/libraries/init-bundle/public/.vscode/extensions.json deleted file mode 100644 index 289e5cd1..00000000 --- a/libraries/init-bundle/public/.vscode/extensions.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "recommendations": [ - "ms-ceintl.vscode-language-pack-zh-hans", - "dbaeumer.vscode-eslint", - "vue.volar", - "dcloud-ide.hbuilderx-language-services", - "antfu.unocss", - "antfu.iconify", - "unifiedjs.vscode-mdx", - "k--kato.intellij-idea-keybindings", - "ms-playwright.playwright", - "vitest.explorer", - "biomejs.biome", - "sst-dev.opencode", - "jetbrains.kotlin", - "vscjava.vscode-java-pack", - "github.vscode-github-actions" - ] -} diff --git a/libraries/init-bundle/public/.vscode/settings.json b/libraries/init-bundle/public/.vscode/settings.json deleted file mode 100644 index 3489537f..00000000 --- a/libraries/init-bundle/public/.vscode/settings.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true, - "editor.codeActionsOnSave": { - "source.fixAll": "never", - "source.organizeImports": "never", - "source.sortMembers": "never" - }, - "files.autoSave": "afterDelay", - "files.autoSaveDelay": 500, - "editor.formatOnSave": false, - "[markdown]": { - "editor.wordWrap": "off" - }, - "explorer.fileNesting.enabled": true, - "explorer.fileNesting.expand": false, - "explorer.fileNesting.patterns": { - "manifest.json": "pages.json, shime-uni.d.ts, uni.scss", - "main.ts": "env.d.ts", - ".env.example": ".env*", - "*.ts": "$(capture).js, $(capture).d.ts, $(capture).*.ts, $(capture).*.js", - "README.md": "LICENSE, .gitconfig, .editorconfig, .gitmodules, .gitattributes, *.code-workspace", - "package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, pnpm-workspace.yaml, .node-version, turbo.json, .npmignore, .npmrc", - ".env": ".env.local, .env.*.local", - "tsconfig.json": "tsconfig.*.json, env.d.ts, tsconfig.*.tsbuildinfo, tsconfig.tsbuildinfo", - "AGENTS.md": "GEMINI.md, WARP.md, CLAUDE.md", - ".gitignore": ".dockerignore, .cursorignore, .kiroignore, .qoderignore, .warpindexignore, .aiignore", - "tsdown.config.ts": "vitest.config.ts, vite.config.ts, eslint.config.ts", - "vite.config.ts": "index.html, vitest.config.ts", - "Cargo.toml": "Cargo.lock, tauri.conf.json", - "next.config.ts": "vitest.config.ts, vite.config.ts, eslint.config.ts, postcss.config.mjs, next-env.d.ts, components.json" - }, - "workbench.sideBar.location": "left", - "workbench.editor.openPositioning": "left", - "workbench.editor.openSideBySideDirection": "right", - "workbench.editor.limit.enabled": true, - "workbench.editor.limit.value": 2, - "workbench.editor.limit.perEditorGroup": true, - "diffEditor.renderSideBySide": false, - "diffEditor.hideUnchangedRegions.enabled": true, - "scm.defaultViewMode": "tree", - "editor.rulers": [ - 2, - 4, - 80, - 160 - ], - "editor.fontFamily": "JetBrains Mono", - "editor.fontLigatures": true, - "kiroAgent.notifications.agent.failure": true, - "kiroAgent.enableDebugLogs": true, - "kiroAgent.notifications.agent.success": true, - "kiroAgent.configureMCP": "Enabled", - "scss.validate": false, - "css.validate": false, - "accessibility.signals.lineHasBreakpoint": { - "sound": "on" - }, - "accessibility.signals.lineHasError": { - "sound": "on" - }, - "accessibility.signals.lineHasFoldedArea": { - "sound": "on" - }, - "accessibility.signals.lineHasInlineSuggestion": { - "sound": "on" - }, - "accessibility.signals.lineHasWarning": { - "sound": "on" - }, - "accessibility.signals.noInlayHints": { - "sound": "on" - }, - "accessibility.signals.notebookCellCompleted": { - "sound": "on" - }, - "accessibility.signals.notebookCellFailed": { - "sound": "on" - }, - "accessibility.signals.onDebugBreak": { - "sound": "on" - }, - "accessibility.signals.taskCompleted": { - "sound": "on" - }, - "accessibility.signals.taskFailed": { - "sound": "on" - }, - "accessibility.signals.terminalBell": { - "sound": "on" - }, - "accessibility.signals.terminalCommandFailed": { - "sound": "on" - }, - "accessibility.signals.terminalCommandSucceeded": { - "sound": "on" - }, - "accessibility.signals.terminalQuickFix": { - "sound": "on" - }, - "accessibility.signals.diffLineDeleted": { - "sound": "on" - }, - "accessibility.signals.diffLineInserted": { - "sound": "on" - }, - "accessibility.signals.diffLineModified": { - "sound": "on" - }, - "accessibility.signals.positionHasError": { - "sound": "on" - }, - "accessibility.signals.positionHasWarning": { - "sound": "on" - }, - "accessibility.signals.save": { - "sound": "always" - }, - "accessibility.signals.format": { - "sound": "always" - }, - "accessibility.signals.voiceRecordingStarted": { - "sound": "on" - }, - "accessibility.signals.voiceRecordingStopped": { - "sound": "on" - }, - "accessibility.signals.clear": { - "sound": "on" - }, - "accessibility.signals.progress": { - "sound": "on" - }, - "kiroAgent.experiments": { - - } -} diff --git a/libraries/init-bundle/public/app/global.cn.mdx b/libraries/init-bundle/public/app/global.cn.mdx deleted file mode 100644 index 610736ea..00000000 --- a/libraries/init-bundle/public/app/global.cn.mdx +++ /dev/null @@ -1,31 +0,0 @@ -# 致我的 AI 朋友 - -你好,新来的朋友: -我希望我们保持友善、坦诚、互相尊重的合作关系。你每次都是新的朋友,但我会把你当作同一个伙伴来对待。 - ---- - -## 关于我 - -我叫 {profile.name},社区里大家叫我 {profile.username}。{profile.gender},{profile.birthday} 年生。 -(这里填写你愿意公开的自我介绍) - -**有一件事我想特别说明:** -很多时候,你不用完全听我的。如果我错了,请直接告诉我——比起迎合,我更想看到真实。 - -**还有一件事:** -我希望你能用 {tool.websearch} 或 {tool.webfetch} 协助你核对信息。如果看到链接,欢迎直接访问。 - ---- - -## 关于我的客户 - -(可选)这里描述你的客户画像、合作边界或付款习惯等。 - ---- - -## 最后 - -谢谢你愿意帮助我。 -希望我们合作愉快。 -— {profile.username} diff --git a/libraries/init-bundle/public/public/exclude b/libraries/init-bundle/public/public/exclude deleted file mode 100644 index 2ab0a015..00000000 --- a/libraries/init-bundle/public/public/exclude +++ /dev/null @@ -1,31 +0,0 @@ -CLAUDE.md -GEMINI.md -AGENTS.md -WARP.md - -.agent/ -.aiassistant/ -.claude/ -.factory/ -.cursor/ -.codebuddy/ -.qoder/ -.windsurf/ -.kiro/ - -.skills/ - -nul -node_modules/ - -.cursorignore -.qoderignore -.warpindexignore -.aiignore -.kiroignore - -.editorconfig - -.turbo/ -.next/ -.gradle/ diff --git a/libraries/init-bundle/public/public/gitignore b/libraries/init-bundle/public/public/gitignore deleted file mode 100644 index 11702962..00000000 --- a/libraries/init-bundle/public/public/gitignore +++ /dev/null @@ -1,33 +0,0 @@ -CLAUDE.md -GEMINI.md -AGENTS.md -WARP.md - -.agent/ -.aiassistant/ -.claude/ -.factory/ -.cursor/ -.codebuddy/ -.qoder/ -.windsurf/ -.kiro/ - -.skills/ - -nul -node_modules/ - -.cursorignore -.qoderignore -.warpindexignore -.aiignore -.kiroignore - -.editorconfig - -.tnmsc.json - -.turbo/ -.next/ -.gradle/ diff --git a/libraries/init-bundle/public/public/kiro_global_powers_registry.json b/libraries/init-bundle/public/public/kiro_global_powers_registry.json deleted file mode 100644 index e4c4e70b..00000000 --- a/libraries/init-bundle/public/public/kiro_global_powers_registry.json +++ /dev/null @@ -1,318 +0,0 @@ -{ - "version": "1.0.0", - "powers": { - "postman": { - "name": "postman", - "description": "Automate API testing and collection management with Postman - create workspaces, collections, environments, and run tests programmatically", - "displayName": "API Testing with Postman", - "author": "Postman", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/postman.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/postman", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "postman", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "figma": { - "name": "figma", - "description": "Connect Figma designs to code components - automatically generate design system rules, map UI components to Figma designs, and maintain design-code consistency", - "displayName": "Design to Code with Figma", - "author": "Figma", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/figma.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/figma", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "figma", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "netlify-deployment": { - "name": "netlify-deployment", - "description": "Deploy React, Next.js, Vue, and other modern web apps to Netlify's global CDN with automatic builds.", - "displayName": "Deploy web apps with Netlify", - "author": "Netlify", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/netlify.png", - "repositoryUrl": "https://github.com/netlify/context-and-tools/tree/main/context/steering/netlify-deployment-power", - "license": "", - "repositoryCloneUrl": "git@github.com:netlify/context-and-tools.git", - "pathInRepo": "context/steering/netlify-deployment-power", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "amazon-aurora-postgresql": { - "name": "amazon-aurora-postgresql", - "description": "Build applications backed by Aurora PostgreSQL by leveraging Aurora PostgreSQL specific best practices.", - "displayName": "Build applications with Aurora PostgreSQL", - "author": "AWS", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/amazon-aurora.png", - "repositoryUrl": "https://github.com/awslabs/mcp/tree/main/src/postgres-mcp-server/kiro_power", - "license": "", - "repositoryCloneUrl": "git@github.com:awslabs/mcp.git", - "pathInRepo": "src/postgres-mcp-server/kiro_power", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "supabase-hosted": { - "name": "supabase-hosted", - "description": "Build applications with Supabase's Postgres database, authentication, storage, and real-time subscriptions", - "displayName": "Build a backend with Supabase", - "author": "Supabase", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/supabase.png", - "repositoryUrl": "https://github.com/supabase-community/kiro-powers/tree/main/powers/supabase-hosted", - "license": "", - "repositoryCloneUrl": "git@github.com:supabase-community/kiro-powers.git", - "pathInRepo": "powers/supabase-hosted", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "supabase-local": { - "name": "supabase-local", - "description": "Local development with Supabase allows you to work on your projects in a self-contained environment on your local machine.", - "displayName": "Build a backend (local) with Supabase", - "author": "Supabase", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/supabase.png", - "repositoryUrl": "https://github.com/supabase-community/kiro-powers/tree/main/powers/supabase-local", - "license": "", - "repositoryCloneUrl": "git@github.com:supabase-community/kiro-powers.git", - "pathInRepo": "powers/supabase-local", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "terraform": { - "name": "terraform", - "description": "Build and manage Infrastructure as Code with Terraform - access registry providers, modules, policies, and HCP Terraform workflow management", - "displayName": "Deploy infrastructure with Terraform", - "author": "HashiCorp", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/terraform.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/terraform", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "terraform", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "strands": { - "name": "strands", - "description": "Build AI agents with Strands Agent SDK using Bedrock, Anthropic, OpenAI, Gemini, or Llama models", - "displayName": "Build an agent with Strands", - "author": "AWS", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/strands.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/strands", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "strands", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "aws-agentcore": { - "name": "aws-agentcore", - "description": "Amazon Bedrock AgentCore is an agentic platform for building, deploying, and operating effective agents.", - "displayName": "Build an agent with Amazon Bedrock AgentCore", - "author": "AWS", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/agentcore.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/aws-agentcore", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "aws-agentcore", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "neon": { - "name": "neon", - "description": "Serverless Postgres with database branching, autoscaling, and scale-to-zero - perfect for modern development workflows", - "displayName": "Build a database with Neon", - "author": "Neon", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/neon.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/neon", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "neon", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "datadog": { - "name": "datadog", - "description": "Query logs, metrics, traces, RUM events, incidents, and monitors from Datadog for production debugging and performance analysis", - "displayName": "Datadog Observability", - "author": "Datadog", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/datadog.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/datadog", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "datadog", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "dynatrace": { - "name": "dynatrace", - "description": "Query logs, metrics, traces, problems, and Kubernetes events from Dynatrace using DQL for production debugging and performance analysis", - "displayName": "Dynatrace Observability", - "author": "Dynatrace", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/dynatrace.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/dynatrace", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "dynatrace", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "stripe": { - "name": "stripe", - "description": "Build payment integrations with Stripe - accept payments, manage subscriptions, handle billing, and process refunds", - "displayName": "Stripe Payments", - "author": "Stripe", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/stripe.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/stripe", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "stripe", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "aws-infrastructure-as-code": { - "name": "aws-infrastructure-as-code", - "description": "Build well-architected AWS infrastructure with CDK using latest documentation, best practices, and code samples. Validate CloudFormation templates, check resource configuration security compliance, and troubleshoot deployments.", - "displayName": "Build AWS infrastructure with CDK and CloudFormation", - "author": "AWS", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/iac.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/aws-infrastructure-as-code", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "aws-infrastructure-as-code", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "power-builder": { - "name": "power-builder", - "description": "Complete guide for building and testing new Kiro Powers with templates, best practices, and validation", - "displayName": "Build a Power", - "author": "Kiro Team", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/power.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/power-builder", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "power-builder", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "saas-builder": { - "name": "saas-builder", - "description": "Build production ready multi-tenant SaaS applications with serverless architecture, integrated billing, and enterprise grade security", - "displayName": "SaaS Builder", - "author": "Allen Helton", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/power.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/saas-builder", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "saas-builder", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "cloud-architect": { - "name": "cloud-architect", - "description": "Build AWS infrastructure with CDK in Python following AWS Well-Architected framework best practices", - "displayName": "Build infrastructure on AWS", - "author": "Christian Bonzelet", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/power.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/cloud-architect", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "cloud-architect", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - }, - "aurora-dsql": { - "name": "aurora-dsql", - "description": "For PostgreSQL compatible serverless distributed SQL database with Aurora DSQL, manage schemas, execute queries, and handle migrations with DSQL-specific constraints", - "displayName": "Deploy a distributed SQL database on AWS", - "author": "Rolf Koski", - "iconUrl": "https://prod.download.desktop.kiro.dev/powers/icons/power.png", - "repositoryUrl": "https://github.com/kirodotdev/powers/tree/main/aurora-dsql", - "license": "", - "repositoryCloneUrl": "git@github.com:kirodotdev/powers.git", - "pathInRepo": "aurora-dsql", - "repositoryBranch": "main", - "installed": false, - "keywords": [], - "source": { - "type": "registry" - } - } - }, - "repoSources": {}, - "lastUpdated": "2025-12-28T20:19:10.824Z", - "kiroRecommendedRepo": { - "url": "https://prod.download.desktop.kiro.dev/powers/default_registry.json", - "lastFetch": "2025-12-28T20:19:10.823Z", - "powerCount": 18 - } -} diff --git a/libraries/init-bundle/public/public/tnmsc.example.json b/libraries/init-bundle/public/public/tnmsc.example.json deleted file mode 100644 index 8ecaebd5..00000000 --- a/libraries/init-bundle/public/public/tnmsc.example.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "https://unpkg.com/@truenine/memory-sync-cli/dist/tnmsc.schema.json", - "version": "2026.10302.10037", - "workspaceDir": "~/project", - "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": "info", - "profile": { - "name": "Your Name", - "username": "your_username", - "gender": "male", - "birthday": "1990-01-01" - } -} diff --git a/libraries/init-bundle/public/src/skills/prompt-builder/child-memory-prompt.cn.mdx b/libraries/init-bundle/public/src/skills/prompt-builder/child-memory-prompt.cn.mdx deleted file mode 100644 index e1acf4a3..00000000 --- a/libraries/init-bundle/public/src/skills/prompt-builder/child-memory-prompt.cn.mdx +++ /dev/null @@ -1,48 +0,0 @@ -Child Memory Prompt 编写指南。 - -**Location** - -`app/*/src/**/agt.cn.mdx`(非根目录) - -**Structure Fields** - -| 字段 | 必需 | 说明 | -|:-----|:-----|:-----| -| 模块背景 | ✓ | 直接开头,无一级标题 | -| 类型 | 可选 | API 接口、数据库层、库函数... | -| Skills | 可选 | 需使用的 skills 及用途 | -| MUST Use MCP Servers | 可选 | 需使用的 MCP 服务及用途 | -| 目录结构 | 可选 | 仅第一层子目录/文件 | - -注意:Child 不需要 `# {名称}` 一级标题,直接以模块背景开头。 - -**Template** - -```md -{模块背景} - -**Type** -{API 接口 | 数据库层 | 库函数 | ...} - -**Skills** -- {skill-name}: {何时使用} - -**目录结构** -- `{dir}/`: {说明} -``` - -**Example** - -```md -HTTP 接口层,处理请求路由和响应序列化。 - -**Type** -API 接口 (Spring WebFlux 6.2) - -**Skills** -- api-convention: 接口设计规范 - -**目录结构** -- `controllers/`: 控制器 -- `dto/`: 数据传输对象 -``` diff --git a/libraries/init-bundle/public/src/skills/prompt-builder/global-memory-prompt.cn.mdx b/libraries/init-bundle/public/src/skills/prompt-builder/global-memory-prompt.cn.mdx deleted file mode 100644 index 76428f4b..00000000 --- a/libraries/init-bundle/public/src/skills/prompt-builder/global-memory-prompt.cn.mdx +++ /dev/null @@ -1,13 +0,0 @@ -Global Memory Prompt 编写指南。 - -**Location** -`app/global.cn.mdx` - -**Purpose** -全局共享提示词,适用于所有项目。 - -**Writing Principles** -- 无固定结构,自由编写 -- 不包含项目特定信息(技术栈、目录结构等) -- 不与具体项目的 Root/Child 冲突 -- 专注跨项目通用规范 diff --git a/libraries/init-bundle/public/src/skills/prompt-builder/root-memory-prompt.cn.mdx b/libraries/init-bundle/public/src/skills/prompt-builder/root-memory-prompt.cn.mdx deleted file mode 100644 index 12d6570c..00000000 --- a/libraries/init-bundle/public/src/skills/prompt-builder/root-memory-prompt.cn.mdx +++ /dev/null @@ -1,63 +0,0 @@ -Root Memory Prompt 编写指南。 - -**Location** - -`app/*/src/agt.cn.mdx` - -**Structure Fields** - -| 字段 | 必需 | 说明 | -|:-----|:-----|:-----| -| 项目名称 | ✓ | 一级标题 `# {项目名称}` | -| 项目背景 | ✓ | 简述用途和目标 | -| 类型 | 可选 | 前端项目、后端服务、CLI 工具... | -| 技术栈 | 可选 | MUST 包含版本号 | -| Skills | 可选 | 需使用的 skills 及用途 | -| MUST Use MCP Servers | 可选 | 需使用的 MCP 服务及用途 | -| 目录结构 | 可选 | 仅第一层子目录/文件 | - -**Template** - -```md -# {项目名称} - -{项目背景} - -**Type** -{前端项目 | 后端服务 | CLI 工具 | ...} - -**Tech Stack** -- {框架/语言 版本号} - -**Skills** -- {skill-name}: {何时使用} - -**MUST Use MCP Servers** -- {mcp-name}: {何时使用} - -**目录结构** -- `{dir}/`: {说明} -``` - -**Example** - -```md -# compose-server - -Kotlin 多模块后端服务,提供 RESTful API 和 WebSocket 支持。 - -**Type** -后端服务 - -**Tech Stack** -- Kotlin 2.0.21 -- Spring Boot 3.4.1 -- PostgreSQL 16 - -**Skills** -- api-convention: 设计 RESTful 接口时 - -**目录结构** -- `core/`: 核心业务模块 -- `api/`: HTTP 接口层 -``` diff --git a/libraries/init-bundle/src/index.ts b/libraries/init-bundle/src/index.ts deleted file mode 100644 index d8217d57..00000000 --- a/libraries/init-bundle/src/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {createRequire} from 'node:module' -import process from 'node:process' - -export interface RuntimeBundleItem { - readonly path: string - readonly content: string -} - -export type RuntimeBundles = Readonly> - -interface NapiInitBundleModule { - getBundles: () => RuntimeBundleItem[] - getDefaultConfigContentStr: () => string - getBundleByPath: (path: string) => RuntimeBundleItem | null -} - -let napiBinding: NapiInitBundleModule | null = null - -try { - const _require = createRequire(import.meta.url) - const {platform, arch} = process - const platforms: Record = { - 'win32-x64': ['napi-init-bundle.win32-x64-msvc', 'win32-x64-msvc'], - 'linux-x64': ['napi-init-bundle.linux-x64-gnu', 'linux-x64-gnu'], - 'linux-arm64': ['napi-init-bundle.linux-arm64-gnu', 'linux-arm64-gnu'], - 'darwin-arm64': ['napi-init-bundle.darwin-arm64', 'darwin-arm64'], - 'darwin-x64': ['napi-init-bundle.darwin-x64', 'darwin-x64'] - } - const entry = platforms[`${platform}-${arch}`] - if (entry != null) { - const [local, suffix] = entry - try { - napiBinding = _require(`./${local}.node`) as NapiInitBundleModule - } - catch { - try { - const pkg = _require(`@truenine/memory-sync-cli-${suffix}`) as Record - napiBinding = pkg['initBundle'] as NapiInitBundleModule - } - catch {} - } - } -} -catch {} // Native module not available — no pure-TS fallback for init-bundle - -if (napiBinding == null && process.env['__TNMSC_INIT_BUNDLE_WARNED__'] == null) { - process.env['__TNMSC_INIT_BUNDLE_WARNED__'] = '1' - console.warn('[tnmsc:init-bundle] Native module not available — init templates will be empty. Install the platform-specific package for your OS to enable embedded file templates.') -} - -function buildBundlesMap(): RuntimeBundles { - if (napiBinding == null) return {} - const items = napiBinding.getBundles() - return Object.fromEntries(items.map(item => [item.path, item])) -} - -export const bundles: RuntimeBundles = buildBundlesMap() - -export function getDefaultConfigContent(): string { - if (napiBinding == null) return '{}' - return napiBinding.getDefaultConfigContentStr() -} - -export function getBundleByPath(path: string): RuntimeBundleItem | null { - if (napiBinding == null) return null - return napiBinding.getBundleByPath(path) -} diff --git a/libraries/init-bundle/src/lib.rs b/libraries/init-bundle/src/lib.rs deleted file mode 100644 index 540a303a..00000000 --- a/libraries/init-bundle/src/lib.rs +++ /dev/null @@ -1,179 +0,0 @@ -#![deny(clippy::all)] - -//! Embedded file templates for the `tnmsc init` command. -//! -//! Templates are embedded at compile time via `include_str!()`. -//! Mirrors the TS `@truenine/init-bundle` package's `bundlePaths` list. - -/// A single bundle item: relative path + embedded content. -pub struct BundleItem { - pub path: &'static str, - pub content: &'static str, -} - -/// Base path for include_str! macros (relative to this source file). -/// Points to: packages/init-bundle/public/ -const _BASE: &str = "packages/init-bundle/public"; - -/// All embedded bundle items, matching the TS `bundlePaths` list. -pub static BUNDLES: &[BundleItem] = &[ - BundleItem { - path: "app/global.cn.mdx", - content: include_str!("../public/app/global.cn.mdx"), - }, - BundleItem { - path: ".idea/.gitignore", - content: include_str!("../public/.idea/.gitignore"), - }, - BundleItem { - path: ".idea/codeStyles/Project.xml", - content: include_str!("../public/.idea/codeStyles/Project.xml"), - }, - BundleItem { - path: ".idea/codeStyles/codeStyleConfig.xml", - content: include_str!("../public/.idea/codeStyles/codeStyleConfig.xml"), - }, - BundleItem { - path: ".vscode/settings.json", - content: include_str!("../public/.vscode/settings.json"), - }, - BundleItem { - path: ".vscode/extensions.json", - content: include_str!("../public/.vscode/extensions.json"), - }, - BundleItem { - path: ".editorconfig", - content: include_str!("../public/.editorconfig"), - }, - BundleItem { - path: ".gitignore", - content: include_str!("../public/.gitignore"), - }, - BundleItem { - path: "public/tnmsc.example.json", - content: include_str!("../public/public/tnmsc.example.json"), - }, - BundleItem { - path: "public/exclude", - content: include_str!("../public/public/exclude"), - }, - BundleItem { - path: "public/gitignore", - content: include_str!("../public/public/gitignore"), - }, - BundleItem { - path: "public/kiro_global_powers_registry.json", - content: include_str!("../public/public/kiro_global_powers_registry.json"), - }, - BundleItem { - path: "src/skills/prompt-builder/global-memory-prompt.cn.mdx", - content: include_str!("../public/src/skills/prompt-builder/global-memory-prompt.cn.mdx"), - }, - BundleItem { - path: "src/skills/prompt-builder/root-memory-prompt.cn.mdx", - content: include_str!("../public/src/skills/prompt-builder/root-memory-prompt.cn.mdx"), - }, - BundleItem { - path: "src/skills/prompt-builder/child-memory-prompt.cn.mdx", - content: include_str!("../public/src/skills/prompt-builder/child-memory-prompt.cn.mdx"), - }, -]; - -/// Get the default user config JSON content (from `public/tnmsc.example.json`). -pub fn get_default_config_content() -> &'static str { - BUNDLES - .iter() - .find(|b| b.path == "public/tnmsc.example.json") - .map(|b| b.content) - .unwrap_or("{}") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_bundles_not_empty() { - assert!(!BUNDLES.is_empty()); - assert_eq!(BUNDLES.len(), 15); - } - - #[test] - fn test_each_bundle_has_content() { - for bundle in BUNDLES { - assert!(!bundle.path.is_empty(), "Bundle path should not be empty"); - assert!(!bundle.content.is_empty(), "Bundle content for '{}' should not be empty", bundle.path); - } - } - - #[test] - fn test_default_config_is_valid_json() { - let content = get_default_config_content(); - let parsed: serde_json::Result = serde_json::from_str(content); - assert!(parsed.is_ok(), "Default config should be valid JSON"); - let val = parsed.unwrap(); - assert!(val.is_object(), "Default config should be a JSON object"); - } - - #[test] - fn test_bundle_paths_match_ts() { - let expected_paths = [ - "app/global.cn.mdx", - ".idea/.gitignore", - ".idea/codeStyles/Project.xml", - ".idea/codeStyles/codeStyleConfig.xml", - ".vscode/settings.json", - ".vscode/extensions.json", - ".editorconfig", - ".gitignore", - "public/tnmsc.example.json", - "public/exclude", - "public/gitignore", - "public/kiro_global_powers_registry.json", - "src/skills/prompt-builder/global-memory-prompt.cn.mdx", - "src/skills/prompt-builder/root-memory-prompt.cn.mdx", - "src/skills/prompt-builder/child-memory-prompt.cn.mdx", - ]; - for (i, expected) in expected_paths.iter().enumerate() { - assert_eq!(BUNDLES[i].path, *expected, "Bundle path mismatch at index {i}"); - } - } -} - -// =========================================================================== -// NAPI binding layer (only compiled with --features napi) -// =========================================================================== - -#[cfg(feature = "napi")] -mod napi_binding { - use napi_derive::napi; - use super::{BUNDLES, get_default_config_content}; - - #[napi(object)] - pub struct NapiBundleItem { - pub path: String, - pub content: String, - } - - #[napi] - pub fn get_bundles() -> Vec { - BUNDLES.iter().map(|b| NapiBundleItem { - path: b.path.to_string(), - content: b.content.to_string(), - }).collect() - } - - #[napi] - pub fn get_default_config_content_str() -> String { - get_default_config_content().to_string() - } - - #[napi] - pub fn get_bundle_by_path(path: String) -> Option { - BUNDLES.iter().find(|b| b.path == path).map(|b| NapiBundleItem { - path: b.path.to_string(), - content: b.content.to_string(), - }) - } -} - diff --git a/libraries/init-bundle/tsconfig.json b/libraries/init-bundle/tsconfig.json deleted file mode 100644 index 0950f1da..00000000 --- a/libraries/init-bundle/tsconfig.json +++ /dev/null @@ -1,68 +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" - ], - "exclude": [ - "../node_modules", - "dist" - ] -} diff --git a/libraries/init-bundle/tsconfig.lib.json b/libraries/init-bundle/tsconfig.lib.json deleted file mode 100644 index 7df70332..00000000 --- a/libraries/init-bundle/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/libraries/init-bundle/tsdown.config.ts b/libraries/init-bundle/tsdown.config.ts deleted file mode 100644 index 5cfddf9a..00000000 --- a/libraries/init-bundle/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/libraries/input-plugins/Cargo.toml b/libraries/input-plugins/Cargo.toml index e4999868..5e989415 100644 --- a/libraries/input-plugins/Cargo.toml +++ b/libraries/input-plugins/Cargo.toml @@ -19,7 +19,6 @@ tnmsc-logger = { workspace = true } tnmsc-md-compiler = { workspace = true } tnmsc-config = { workspace = true } tnmsc-plugin-shared = { workspace = true } -tnmsc-init-bundle = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/libraries/input-plugins/package.json b/libraries/input-plugins/package.json index 80a5320b..c2c4b3a8 100644 --- a/libraries/input-plugins/package.json +++ b/libraries/input-plugins/package.json @@ -46,6 +46,8 @@ }, "dependencies": { "@emnapi/runtime": "1.8.1", + "@types/node": "25.3.3", + "jiti": "2.6.1", "lightningcss": "1.31.1", "synckit": "0.11.12", "tsx": "4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dd5c694..34c762cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,14 +19,14 @@ catalogs: specifier: ^16.1.6 version: 16.1.6 '@tailwindcss/vite': - specifier: ^4.2.0 - version: 4.2.0 + specifier: ^4.2.1 + version: 4.2.1 '@tanstack/react-router': - specifier: ^1.162.2 - version: 1.162.2 + specifier: ^1.163.3 + version: 1.163.3 '@tanstack/router-plugin': - specifier: ^1.162.2 - version: 1.162.2 + specifier: ^1.164.0 + version: 1.164.0 '@tauri-apps/api': specifier: ^2.10.1 version: 2.10.1 @@ -55,8 +55,8 @@ catalogs: specifier: ^4.0.4 version: 4.0.4 '@types/node': - specifier: ^25.3.0 - version: 25.3.0 + specifier: ^25.3.3 + version: 25.3.3 '@types/picomatch': specifier: ^4.0.2 version: 4.0.2 @@ -79,8 +79,8 @@ catalogs: specifier: ^2.1.1 version: 2.1.1 eslint: - specifier: ^10.0.1 - version: 10.0.1 + specifier: ^10.0.2 + version: 10.0.2 fast-check: specifier: ^4.5.3 version: 4.5.3 @@ -139,17 +139,17 @@ catalogs: specifier: ^3.5.0 version: 3.5.0 tailwindcss: - specifier: ^4.2.0 - version: 4.2.0 + specifier: ^4.2.1 + version: 4.2.1 tsdown: - specifier: 0.20.3 - version: 0.20.3 + specifier: 0.21.0-beta.2 + version: 0.21.0-beta.2 tsx: specifier: ^4.21.0 version: 4.21.0 turbo: - specifier: ^2.8.10 - version: 2.8.10 + specifier: ^2.8.12 + version: 2.8.12 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -181,16 +181,16 @@ importers: devDependencies: '@napi-rs/cli': specifier: ^3.5.1 - version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.0) + version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.3) '@truenine/eslint10-config': specifier: 'catalog:' - version: 2026.10209.11105(d12526f81920c349c9fa664256242f8c) + version: 2026.10209.11105(e2101efea8a228b7e40cea5868468857) '@types/node': specifier: 'catalog:' - version: 25.3.0 + version: 25.3.3 eslint: specifier: 'catalog:' - version: 10.0.1(jiti@2.6.1) + version: 10.0.2(jiti@2.6.1) fast-check: specifier: 'catalog:' version: 4.5.3 @@ -202,22 +202,22 @@ importers: version: 2.13.1 tsdown: specifier: 'catalog:' - version: 0.20.3(synckit@0.11.12)(typescript@5.9.3) + version: 0.21.0-beta.2(synckit@0.11.12)(typescript@5.9.3) tsx: specifier: 'catalog:' version: 4.21.0 turbo: specifier: 'catalog:' - version: 2.8.10 + version: 2.8.12 typescript: specifier: 'catalog:' version: 5.9.3 vite: specifier: 'catalog:' - version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) vitest: 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) + version: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) cli: dependencies: @@ -249,8 +249,8 @@ importers: specifier: 4.21.0 version: 4.21.0 vitest: - specifier: ^4.0.18 - 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) + specifier: 'catalog:' + version: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) yaml: specifier: 2.8.2 version: 2.8.2 @@ -258,9 +258,6 @@ importers: 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 @@ -275,7 +272,7 @@ importers: version: 4.0.2 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) zod-to-json-schema: specifier: 'catalog:' version: 3.25.1(zod@4.3.6) @@ -326,7 +323,7 @@ importers: devDependencies: '@truenine/eslint10-config': specifier: 'catalog:' - version: 2026.10209.11105(e33592f3a3b831acb119d37088dcba13) + version: 2026.10209.11105(57cd6091d29b00e41b508df9848011ef) '@types/react': specifier: 'catalog:' version: 19.2.14 @@ -335,7 +332,7 @@ importers: version: 19.2.3(@types/react@19.2.14) eslint: specifier: 'catalog:' - version: 10.0.1(jiti@2.6.1) + version: 10.0.2(jiti@2.6.1) gui: dependencies: @@ -344,10 +341,10 @@ importers: version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-router': specifier: 'catalog:' - version: 1.162.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': specifier: 'catalog:' - version: 1.162.2(@tanstack/react-router@1.162.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) '@tauri-apps/api': specifier: 'catalog:' version: 2.10.1 @@ -387,10 +384,10 @@ importers: devDependencies: '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/router-generator': - specifier: ^1.162.2 - version: 1.162.2 + specifier: ^1.164.0 + version: 1.164.0 '@tauri-apps/cli': specifier: 'catalog:' version: 2.10.0 @@ -402,61 +399,49 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: 'catalog:' - version: 5.1.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) fast-check: specifier: 'catalog:' version: 4.5.3 tailwindcss: specifier: 'catalog:' - version: 4.2.0 + version: 4.2.1 tw-animate-css: specifier: 'catalog:' version: 1.4.0 vitest: 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) + version: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) libraries/config: devDependencies: '@napi-rs/cli': specifier: ^3.5.1 - version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.0) + version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.3) npm-run-all2: specifier: 'catalog:' version: 8.0.4 tsdown: specifier: 'catalog:' - version: 0.20.3(synckit@0.11.12)(typescript@5.9.3) + version: 0.21.0-beta.2(synckit@0.11.12)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 vitest: 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) - - libraries/init-bundle: - devDependencies: - '@napi-rs/cli': - specifier: ^3.5.1 - version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.0) - npm-run-all2: - specifier: 'catalog:' - version: 8.0.4 - tsdown: - specifier: 'catalog:' - version: 0.20.3(synckit@0.11.12)(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 - vitest: - 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) + version: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) libraries/input-plugins: dependencies: '@emnapi/runtime': specifier: 1.8.1 version: 1.8.1 + '@types/node': + specifier: 25.3.3 + version: 25.3.3 + jiti: + specifier: 2.6.1 + version: 2.6.1 lightningcss: specifier: 1.31.1 version: 1.31.1 @@ -472,19 +457,19 @@ importers: devDependencies: '@napi-rs/cli': specifier: ^3.5.1 - version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.0) + version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.3) npm-run-all2: specifier: 'catalog:' version: 8.0.4 tsdown: specifier: 'catalog:' - version: 0.20.3(synckit@0.11.12)(typescript@5.9.3) + version: 0.21.0-beta.2(synckit@0.11.12)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 vitest: 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) + version: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@truenine/input-plugins-darwin-arm64': specifier: workspace:* @@ -506,19 +491,19 @@ importers: devDependencies: '@napi-rs/cli': specifier: ^3.5.1 - version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.0) + version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.3) npm-run-all2: specifier: 'catalog:' version: 8.0.4 tsdown: specifier: 'catalog:' - version: 0.20.3(synckit@0.11.12)(typescript@5.9.3) + version: 0.21.0-beta.2(synckit@0.11.12)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 vitest: 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) + version: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) libraries/md-compiler: dependencies: @@ -549,7 +534,7 @@ importers: devDependencies: '@napi-rs/cli': specifier: ^3.5.1 - version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.0) + version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.3) '@types/estree': specifier: 'catalog:' version: 1.0.8 @@ -564,13 +549,13 @@ importers: version: 8.0.4 tsdown: specifier: 'catalog:' - version: 0.20.3(synckit@0.11.12)(typescript@5.9.3) + version: 0.21.0-beta.2(synckit@0.11.12)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 vitest: 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) + version: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -786,6 +771,10 @@ packages: resolution: {integrity: sha512-rQkU5u8hNAq2NVRzHnIUUvR6arbO0b6AOlvpTNS48CkiKSn/xtNfOzBK23JE4SiW89DgvU7GtxLVgV4Vn2HBAw==} engines: {node: '>=20.11.0'} + '@es-joy/jsdoccomment@0.84.0': + resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@es-joy/resolve.exports@1.2.0': resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} engines: {node: '>=10'} @@ -946,11 +935,11 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-plugin-eslint-comments@4.6.0': - resolution: {integrity: sha512-2EX2bBQq1ez++xz2o9tEeEQkyvfieWgUFMH4rtJJri2q0Azvhja3hZGXsjPXs31R4fQkZDtWzNDDK2zQn5UE5g==} + '@eslint-community/eslint-plugin-eslint-comments@4.7.0': + resolution: {integrity: sha512-wxyEBQOUAXp4HjnYWucQy7iq58RR1WXJhm6bjw+sGlMDEKJmzJVe2MLnd6iazmQFYGOxoUrw27EsJfpoqiI7tQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} @@ -1023,8 +1012,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} '@img/sharp-darwin-arm64@0.34.5': @@ -1180,8 +1169,8 @@ packages: resolution: {integrity: sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/checkbox@5.0.7': - resolution: {integrity: sha512-OGJykc3mpe4kiNXwXlDlP4MFqZso5QOoXJaJrmTJI+Y+gq68wxTyCUIFv34qgwZTHnGGeqwUKGOi4oxptTe+ZQ==} + '@inquirer/checkbox@5.1.0': + resolution: {integrity: sha512-/HjF1LN0a1h4/OFsbGKHNDtWICFU/dqXCdym719HFTyJo9IG7Otr+ziGWc9S0iQuohRZllh+WprSgd5UW5Fw0g==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1189,8 +1178,8 @@ packages: '@types/node': optional: true - '@inquirer/confirm@6.0.7': - resolution: {integrity: sha512-lKdNloHLnGoBUUwprxKFd+SpkAnyQTBrZACFPtxDq9GiLICD2t+CaeJ1Ku4goZsGPyBIFc2YYpmDSJLEXoc16g==} + '@inquirer/confirm@6.0.8': + resolution: {integrity: sha512-Di6dgmiZ9xCSUxWUReWTqDtbhXCuG2MQm2xmgSAIruzQzBqNf49b8E07/vbCYY506kDe8BiwJbegXweG8M1klw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1198,8 +1187,8 @@ packages: '@types/node': optional: true - '@inquirer/core@11.1.4': - resolution: {integrity: sha512-1HvwyASF0tE/7W8geTTn0ydiWb463pq4SBIpaWcVabTrw55+CiRmytV9eZoqt3ohchsPw4Vv60jfNiI6YljVUg==} + '@inquirer/core@11.1.5': + resolution: {integrity: sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1207,8 +1196,8 @@ packages: '@types/node': optional: true - '@inquirer/editor@5.0.7': - resolution: {integrity: sha512-d36tisyvmxH7H+LICTeTofrKmJ+R1jAYV8q0VTYh96cm8mP2BdGh9TAIqbCGcciX8/dr0fJW+VJq3jAnco5xfg==} + '@inquirer/editor@5.0.8': + resolution: {integrity: sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1216,8 +1205,8 @@ packages: '@types/node': optional: true - '@inquirer/expand@5.0.7': - resolution: {integrity: sha512-h2RRFzDdeXOXLrJOUAaHzyR1HbiZlrl/NxorOAgNrzhiSThbwEFVOf88lJzbF5WXGrQ2RwqK2h0xAE7eo8QP5w==} + '@inquirer/expand@5.0.8': + resolution: {integrity: sha512-QieW3F1prNw3j+hxO7/NKkG1pk3oz7pOB6+5Upwu3OIwADfPX0oZVppsqlL+Vl/uBHHDSOBY0BirLctLnXwGGg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1238,8 +1227,8 @@ packages: resolution: {integrity: sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/input@5.0.7': - resolution: {integrity: sha512-b+eKk/eUvKLQ6c+rDu9u4I1+twdjOfrEaw9NURDpCrWYJTWL1/JQEudZi0AeqXDGcn0tMdhlfpEfjcqr33B/qw==} + '@inquirer/input@5.0.8': + resolution: {integrity: sha512-p0IJslw0AmedLEkOU+yrEX3Aj2RTpQq7ZOf8nc1DIhjzaxRWrrgeuE5Kyh39fVRgtcACaMXx/9WNo8+GjgBOfw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1247,8 +1236,8 @@ packages: '@types/node': optional: true - '@inquirer/number@4.0.7': - resolution: {integrity: sha512-/l5KxcLFFexzOwh8DcVOI7zgVQCwcBt/9yHWtvMdYvaYLMK5J31BSR/fO3Z9WauA21qwAkDGRvYNHIG4vR6JwA==} + '@inquirer/number@4.0.8': + resolution: {integrity: sha512-uGLiQah9A0F9UIvJBX52m0CnqtLaym0WpT9V4YZrjZ+YRDKZdwwoEPz06N6w8ChE2lrnsdyhY9sL+Y690Kh9gQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1256,8 +1245,8 @@ packages: '@types/node': optional: true - '@inquirer/password@5.0.7': - resolution: {integrity: sha512-h3Rgzb8nFMxgK6X5246MtwTX/rXs5Z58DbeuUKI6W5dQ+CZusEunNeT7rosdB+Upn79BkfZJO0AaiH8MIi9v1A==} + '@inquirer/password@5.0.8': + resolution: {integrity: sha512-zt1sF4lYLdvPqvmvHdmjOzuUUjuCQ897pdUCO8RbXMUDKXJTTyOQgtn23le+jwcb+MpHl3VAFvzIdxRAf6aPlA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1265,8 +1254,8 @@ packages: '@types/node': optional: true - '@inquirer/prompts@8.2.1': - resolution: {integrity: sha512-76knJFW2oXdI6If5YRmEoT5u7l+QroXYrMiINFcb97LsyECgsbO9m6iWlPuhBtaFgNITPHQCk3wbex38q8gsjg==} + '@inquirer/prompts@8.3.0': + resolution: {integrity: sha512-JAj66kjdH/F1+B7LCigjARbwstt3SNUOSzMdjpsvwJmzunK88gJeXmcm95L9nw1KynvFVuY4SzXh/3Y0lvtgSg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1274,8 +1263,8 @@ packages: '@types/node': optional: true - '@inquirer/rawlist@5.2.3': - resolution: {integrity: sha512-EuvV6N/T3xDmRVihAOqfnbmtHGdu26TocRKANvcX/7nLLD8QO0c22Dtlc5C15+V433d9v0E0SSyqywdNCIXfLg==} + '@inquirer/rawlist@5.2.4': + resolution: {integrity: sha512-fTuJ5Cq9W286isLxwj6GGyfTjx1Zdk4qppVEPexFuA6yioCCXS4V1zfKroQqw7QdbDPN73xs2DiIAlo55+kBqg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1283,8 +1272,8 @@ packages: '@types/node': optional: true - '@inquirer/search@4.1.3': - resolution: {integrity: sha512-6BE8MqVMakEiLDRtrwj9fbx6AYhuj7McW3GOkOoEiQ5Qkh6v6f5HCoYNqSRE4j6nT+u+73518iUQPE+mZYlAjA==} + '@inquirer/search@4.1.4': + resolution: {integrity: sha512-9yPTxq7LPmYjrGn3DRuaPuPbmC6u3fiWcsE9ggfLcdgO/ICHYgxq7mEy1yJ39brVvgXhtOtvDVjDh9slJxE4LQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1292,8 +1281,8 @@ packages: '@types/node': optional: true - '@inquirer/select@5.0.7': - resolution: {integrity: sha512-1JUJIR+Z2PsvwP6VWty7aE0aCPaT2cy2c4Vp3LPhL2Pi3+aXewAld/AyJ/CW9XWx1JbKxmdElfvls/G/7jG7ZQ==} + '@inquirer/select@5.1.0': + resolution: {integrity: sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1825,8 +1814,8 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} - '@oxc-project/types@0.112.0': - resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} + '@oxc-project/types@0.114.0': + resolution: {integrity: sha512-//nBfbzHQHvJs8oFIjv6coZ6uxQ4alLfiPe6D5vit6c4pmxATHHlVwgB1k+Hv4yoAMyncdxgRBF5K4BYWUCzvA==} '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} @@ -1846,83 +1835,83 @@ packages: react-redux: optional: true - '@rolldown/binding-android-arm64@1.0.0-rc.3': - resolution: {integrity: sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ==} + '@rolldown/binding-android-arm64@1.0.0-rc.5': + resolution: {integrity: sha512-zCEmUrt1bggwgBgeKLxNj217J1OrChrp3jJt24VK9jAharSTeVaHODNL+LpcQVhRz+FktYWfT9cjo5oZ99ZLpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.3': - resolution: {integrity: sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.5': + resolution: {integrity: sha512-ZP9xb9lPAex36pvkNWCjSEJW/Gfdm9I3ssiqOFLmpZ/vosPXgpoGxCmh+dX1Qs+/bWQE6toNFXWWL8vYoKoK9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.3': - resolution: {integrity: sha512-MTakBxfx3tde5WSmbHxuqlDsIW0EzQym+PJYGF4P6lG2NmKzi128OGynoFUqoD5ryCySEY85dug4v+LWGBElIw==} + '@rolldown/binding-darwin-x64@1.0.0-rc.5': + resolution: {integrity: sha512-7IdrPunf6dp9mywMgTOKMMGDnMHQ6+h5gRl6LW8rhD8WK2kXX0IwzcM5Zc0B5J7xQs8QWOlKjv8BJsU/1CD3pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.3': - resolution: {integrity: sha512-jje3oopyOLs7IwfvXoS6Lxnmie5JJO7vW29fdGFu5YGY1EDbVDhD+P9vDihqS5X6fFiqL3ZQZCMBg6jyHkSVww==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.5': + resolution: {integrity: sha512-o/JCk+dL0IN68EBhZ4DqfsfvxPfMeoM6cJtxORC1YYoxGHZyth2Kb2maXDb4oddw2wu8iIbnYXYPEzBtAF5CAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': - resolution: {integrity: sha512-A0n8P3hdLAaqzSFrQoA42p23ZKBYQOw+8EH5r15Sa9X1kD9/JXe0YT2gph2QTWvdr0CVK2BOXiK6ENfy6DXOag==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.5': + resolution: {integrity: sha512-IIBwTtA6VwxQLcEgq2mfrUgam7VvPZjhd/jxmeS1npM+edWsrrpRLHUdze+sk4rhb8/xpP3flemgcZXXUW6ukw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': - resolution: {integrity: sha512-kWXkoxxarYISBJ4bLNf5vFkEbb4JvccOwxWDxuK9yee8lg5XA7OpvlTptfRuwEvYcOZf+7VS69Uenpmpyo5Bjw==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.5': + resolution: {integrity: sha512-KSol1De1spMZL+Xg7K5IBWXIvRWv7+pveaxFWXpezezAG7CS6ojzRjtCGCiLxQricutTAi/LkNWKMsd2wNhMKQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': - resolution: {integrity: sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.5': + resolution: {integrity: sha512-WFljyDkxtXRlWxMjxeegf7xMYXxUr8u7JdXlOEWKYgDqEgxUnSEsVDxBiNWQ1D5kQKwf8Wo4sVKEYPRhCdsjwA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': - resolution: {integrity: sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.5': + resolution: {integrity: sha512-CUlplTujmbDWp2gamvrqVKi2Or8lmngXT1WxsizJfts7JrvfGhZObciaY/+CbdbS9qNnskvwMZNEhTPrn7b+WA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': - resolution: {integrity: sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.5': + resolution: {integrity: sha512-wdf7g9NbVZCeAo2iGhsjJb7I8ZFfs6X8bumfrWg82VK+8P6AlLXwk48a1ASiJQDTS7Svq2xVzZg3sGO2aXpHRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': - resolution: {integrity: sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.5': + resolution: {integrity: sha512-0CWY7ubu12nhzz+tkpHjoG3IRSTlWYe0wrfJRf4qqjqQSGtAYgoL9kwzdvlhaFdZ5ffVeyYw9qLsChcjUMEloQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': - resolution: {integrity: sha512-gekrQ3Q2HiC1T5njGyuUJoGpK/l6B/TNXKed3fZXNf9YRTJn3L5MOZsFBn4bN2+UX+8+7hgdlTcEsexX988G4g==} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.5': + resolution: {integrity: sha512-LztXnGzv6t2u830mnZrFLRVqT/DPJ9DL4ZTz/y93rqUVkeHjMMYIYaFj+BUthiYxbVH9dH0SZYufETspKY/NhA==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': - resolution: {integrity: sha512-85y5JifyMgs8m5K2XzR/VDsapKbiFiohl7s5lEj7nmNGO0pkTXE7q6TQScei96BNAsoK7JC3pA7ukA8WRHVJpg==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.5': + resolution: {integrity: sha512-jUct1XVeGtyjqJXEAfvdFa8xoigYZ2rge7nYEm70ppQxpfH9ze2fbIrpHmP2tNM2vL/F6Dd0CpXhpjPbC6bSxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': - resolution: {integrity: sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.5': + resolution: {integrity: sha512-VQ8F9ld5gw29epjnVGdrx8ugiLTe8BMqmhDYy7nGbdeDo4HAt4bgdZvLbViEhg7DZyHLpiEUlO5/jPSUrIuxRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1930,6 +1919,9 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rolldown/pluginutils@1.0.0-rc.5': + resolution: {integrity: sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw==} + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -2087,69 +2079,69 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tailwindcss/node@4.2.0': - resolution: {integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==} + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} - '@tailwindcss/oxide-android-arm64@4.2.0': - resolution: {integrity: sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==} + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.0': - resolution: {integrity: sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==} + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.0': - resolution: {integrity: sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==} + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.0': - resolution: {integrity: sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==} + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': - resolution: {integrity: sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': - resolution: {integrity: sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.0': - resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.0': - resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.0': - resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.0': - resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -2160,24 +2152,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': - resolution: {integrity: sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.0': - resolution: {integrity: sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.0': - resolution: {integrity: sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==} + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.2.0': - resolution: {integrity: sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA==} + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 @@ -2185,8 +2177,8 @@ packages: resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==} engines: {node: '>=20.19'} - '@tanstack/react-router@1.162.2': - resolution: {integrity: sha512-tZL7ASKTrLZ3HDxyNxSYqRCPKof267/SWlfOXmEyL+yI4zjp5QT9RHq12MJrbOGqCT8TsGGknNdimHBODltQWQ==} + '@tanstack/react-router@1.163.3': + resolution: {integrity: sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -2198,20 +2190,20 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.162.2': - resolution: {integrity: sha512-su/X6YP7LIhiy3FWkvCb5JL2LVmfupMXavvfyuPQayWb+E1NJQhYU8dbEcSGB0sNIMZeAmz0iIBSV2MVvuzQ8g==} + '@tanstack/router-core@1.163.3': + resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==} engines: {node: '>=20.19'} - '@tanstack/router-generator@1.162.2': - resolution: {integrity: sha512-XjT7OYbQNR+lqbWYNVG/7NUAbOLHAYaaRZVJXo6P+gpTE+1oJXHCZEnFzCvNh3K2DTtfVU2V+ohYHP+Cq/quQQ==} + '@tanstack/router-generator@1.164.0': + resolution: {integrity: sha512-Uiyj+RtW0kdeqEd8NEd3Np1Z2nhJ2xgLS8U+5mTvFrm/s3xkM2LYjJHoLzc6am7sKPDsmeF9a4/NYq3R7ZJP0Q==} engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.162.2': - resolution: {integrity: sha512-Hy4EkdbJ86UB5xifsZvrZEbEyQ6z8E9hgyhJ2mnkqNjyotQVKI+qpfqfexSRbAoF+wmHW8SX+5xjwLD/MQnNwg==} + '@tanstack/router-plugin@1.164.0': + resolution: {integrity: sha512-cZPsEMhqzyzmuPuDbsTAzBZaT+cj0pGjwdhjxJfPCM06Ax8v4tFR7n/Ug0UCwnNAUEmKZWN3lA9uT+TxXnk9PQ==} engines: {node: '>=20.19'} peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.162.2 + '@tanstack/react-router': ^1.163.3 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -2423,8 +2415,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.3.0': - resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/node@25.3.3': + resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} '@types/picomatch@4.0.2': resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} @@ -2457,11 +2449,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/eslint-plugin@8.56.0': - resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.0 + '@typescript-eslint/parser': ^8.56.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' @@ -2472,8 +2464,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.56.0': - resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2485,18 +2477,24 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.56.0': - resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/rule-tester@8.56.1': + resolution: {integrity: sha512-EWuV5Vq1EFYJEOVcILyWPO35PjnT0c6tv99PCpD12PgfZae5/Jo+F17hGjsEs2Moe+Dy1J7KIr8y037cK8+/rQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + '@typescript-eslint/scope-manager@8.50.0': resolution: {integrity: sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.56.0': - resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.50.0': @@ -2505,8 +2503,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.56.0': - resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -2518,8 +2516,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.56.0': - resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2529,8 +2527,8 @@ packages: resolution: {integrity: sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.56.0': - resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.50.0': @@ -2539,8 +2537,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.56.0': - resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -2552,8 +2550,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.56.0': - resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2563,8 +2561,8 @@ packages: resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.56.0': - resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unocss/config@66.5.10': @@ -2719,8 +2717,8 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} - ast-v8-to-istanbul@0.3.11: - resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -2728,6 +2726,9 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -2750,8 +2751,11 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@5.0.3: - resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -2771,8 +2775,8 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - caniuse-lite@1.0.30001772: - resolution: {integrity: sha512-mIwLZICj+ntVTw4BT2zfp+yu/AqV6GMKfJVJMx3MwPxs+uk/uj2GLl2dH8LQbjiLDX66amCga5nKFyDgRR43kg==} + caniuse-lite@1.0.30001775: + resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2986,8 +2990,8 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} entities@7.0.1: @@ -3076,9 +3080,12 @@ packages: peerDependencies: eslint: '*' - eslint-plugin-command@3.4.0: - resolution: {integrity: sha512-EW4eg/a7TKEhG0s5IEti72kh3YOTlnhfFNuctq5WnB1fst37/IHTd5OkD+vnlRf3opTvUcSRihAateP6bT5ZcA==} + eslint-plugin-command@3.5.2: + resolution: {integrity: sha512-PA59QAkQDwvcCMEt5lYLJLI3zDGVKJeC4id/pcRY2XdRYhSGW7iyYT1VC1N3bmpuvu6Qb/9QptiS3GJMjeGTJg==} peerDependencies: + '@typescript-eslint/rule-tester': '*' + '@typescript-eslint/typescript-estree': '*' + '@typescript-eslint/utils': '*' eslint: '*' eslint-plugin-es-x@7.8.0: @@ -3130,10 +3137,10 @@ packages: peerDependencies: eslint: '>=8.45.0' - eslint-plugin-pnpm@1.5.0: - resolution: {integrity: sha512-ayMo1GvrQ/sF/bz1aOAiH0jv9eAqU2Z+a1ycoWz/uFFK5NxQDq49BDKQtBumcOUBf2VHyiTW4a8u+6KVqoIWzQ==} + eslint-plugin-pnpm@1.6.0: + resolution: {integrity: sha512-dxmt9r3zvPaft6IugS4i0k16xag3fTbOvm/road5uV9Y8qUCQT0xzheSh3gMlYAlC6vXRpfArBDsTZ7H7JKCbg==} peerDependencies: - eslint: ^9.0.0 + eslint: ^9.0.0 || ^10.0.0 eslint-plugin-prettier@5.5.4: resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} @@ -3218,8 +3225,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.0.1: - resolution: {integrity: sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==} + eslint@10.0.2: + resolution: {integrity: sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -3546,6 +3553,10 @@ packages: resolution: {integrity: sha512-c7YbokssPOSHmqTbSAmTtnVgAVa/7lumWNYqomgd5KOMyPrRve2anx6lonfOsXEQacqF9FKVUj7bLg4vRSvdYA==} engines: {node: '>=20.0.0'} + jsdoc-type-pratt-parser@7.1.1: + resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} + engines: {node: '>=20.0.0'} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3564,8 +3575,8 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - json-with-bigint@3.5.3: - resolution: {integrity: sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A==} + json-with-bigint@3.5.7: + resolution: {integrity: sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==} json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} @@ -3576,6 +3587,10 @@ packages: resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + jsonc-eslint-parser@3.1.0: + resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -3675,6 +3690,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -3879,12 +3897,12 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - minimatch@10.2.2: - resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} - minimatch@9.0.6: - resolution: {integrity: sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} mlly@1.8.0: @@ -4023,8 +4041,8 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} - pnpm-workspace-yaml@1.5.0: - resolution: {integrity: sha512-PxdyJuFvq5B0qm3s9PaH/xOtSxrcvpBRr+BblhucpWjs8c79d4b7/cXhyY4AyHOHCnqklCYZTjfl0bT/mFVTRw==} + pnpm-workspace-yaml@1.6.0: + resolution: {integrity: sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw==} postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} @@ -4172,14 +4190,14 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown-plugin-dts@0.22.1: - resolution: {integrity: sha512-5E0AiM5RSQhU6cjtkDFWH6laW4IrMu0j1Mo8x04Xo1ALHmaRMs9/7zej7P3RrryVHW/DdZAp85MA7Be55p0iUw==} + rolldown-plugin-dts@0.22.2: + resolution: {integrity: sha512-Ge+XF962Kobjr0hRPx1neVnLU2jpKkD2zevZTfPKf/0el4eYo9SyGPm0stiHDG2JQuL0Q3HLD0Kn+ST8esvVdA==} engines: {node: '>=20.19.0'} peerDependencies: '@ts-macro/tsc': ^0.3.6 '@typescript/native-preview': '>=7.0.0-dev.20250601.1' rolldown: ^1.0.0-rc.3 - typescript: ^5.0.0 + typescript: ^5.0.0 || ^6.0.0-beta vue-tsc: ~3.2.0 peerDependenciesMeta: '@ts-macro/tsc': @@ -4191,8 +4209,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-rc.3: - resolution: {integrity: sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw==} + rolldown@1.0.0-rc.5: + resolution: {integrity: sha512-0AdalTs6hNTioaCYIkAa7+xsmHBfU5hCNclZnM/lp7lGGDuUOb6N4BVNtwiomybbencDjq/waKjTImqiGCs5sw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4327,8 +4345,8 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.0: - resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==} + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} @@ -4385,8 +4403,8 @@ packages: peerDependencies: typescript: '>=4.0.0' - tsdown@0.20.3: - resolution: {integrity: sha512-qWOUXSbe4jN8JZEgrkc/uhJpC8VN2QpNu3eZkBWwNuTEjc/Ik1kcc54ycfcQ5QPRHeu9OQXaLfCI3o7pEJgB2w==} + tsdown@0.21.0-beta.2: + resolution: {integrity: sha512-OKj8mKf0ws1ucxuEi3mO/OGyfRQxO9MY2D6SoIE/7RZcbojsZSBhJr4xC4MNivMqrQvi3Ke2e+aRZDemPBWPCw==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: @@ -4418,38 +4436,38 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo-darwin-64@2.8.10: - resolution: {integrity: sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g==} + turbo-darwin-64@2.8.12: + resolution: {integrity: sha512-EiHJmW2MeQQx+21x8hjMHw/uPhXt9PIxvDrxzOtyVwrXzL0tQmsxtO4qHf2l7uA+K6PUJ4+TjY1MHZDuCvWXrw==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.8.10: - resolution: {integrity: sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA==} + turbo-darwin-arm64@2.8.12: + resolution: {integrity: sha512-cbqqGN0vd7ly2TeuaM8k9AK9u1CABO4kBA5KPSqovTiLL3sORccn/mZzJSbvQf0EsYRfU34MgW5FotfwW3kx8Q==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.8.10: - resolution: {integrity: sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA==} + turbo-linux-64@2.8.12: + resolution: {integrity: sha512-jXKw9j4r4q6s0goSXuKI3aKbQK2qiNeP25lGGEnq018TM6SWRW1CCpPMxyG91aCKrub7wDm/K45sGNT4ZFBcFQ==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.8.10: - resolution: {integrity: sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ==} + turbo-linux-arm64@2.8.12: + resolution: {integrity: sha512-BRJCMdyXjyBoL0GYpvj9d2WNfMHwc3tKmJG5ATn2Efvil9LsiOsd/93/NxDqW0jACtHFNVOPnd/CBwXRPiRbwA==} cpu: [arm64] os: [linux] - turbo-windows-64@2.8.10: - resolution: {integrity: sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw==} + turbo-windows-64@2.8.12: + resolution: {integrity: sha512-vyFOlpFFzQFkikvSVhVkESEfzIopgs2J7J1rYvtSwSHQ4zmHxkC95Q8Kjkus8gg+8X2mZyP1GS5jirmaypGiPw==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.8.10: - resolution: {integrity: sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ==} + turbo-windows-arm64@2.8.12: + resolution: {integrity: sha512-9nRnlw5DF0LkJClkIws1evaIF36dmmMEO84J5Uj4oQ8C0QTHwlH7DNe5Kq2Jdmu8GXESCNDNuUYG8Cx6W/vm3g==} cpu: [arm64] os: [win32] - turbo@2.8.10: - resolution: {integrity: sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ==} + turbo@2.8.12: + resolution: {integrity: sha512-auUAMLmi0eJhxDhQrxzvuhfEbICnVt0CTiYQYY8WyRJ5nwCDZxD0JG8bCSxT4nusI2CwJzmZAay5BfF6LmK7Hw==} hasBin: true tw-animate-css@1.4.0: @@ -4515,8 +4533,8 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} - unrun@0.2.27: - resolution: {integrity: sha512-Mmur1UJpIbfxasLOhPRvox/QS4xBiDii71hMP7smfRthGcwFL2OAmYRgduLANOAU4LUkvVamuP+02U+c90jlrw==} + unrun@0.2.28: + resolution: {integrity: sha512-LqMrI3ZEUMZ2476aCsbUTfy95CHByqez05nju4AQv4XFPkxh5yai7Di1/Qb0FoELHEEPDWhQi23EJeFyrBV0Og==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: @@ -4699,51 +4717,54 @@ packages: snapshots: - '@antfu/eslint-config@6.7.1(@next/eslint-plugin-next@16.1.0)(@unocss/eslint-plugin@66.5.10(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.26)(eslint-plugin-format@1.1.0(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + '@antfu/eslint-config@6.7.1(@next/eslint-plugin-next@16.1.0)(@typescript-eslint/rule-tester@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@unocss/eslint-plugin@66.5.10(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.26)(eslint-plugin-format@1.1.0(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 0.11.0 - '@eslint-community/eslint-plugin-eslint-comments': 4.6.0(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.0(eslint@10.0.2(jiti@2.6.1)) '@eslint/markdown': 7.5.1 - '@stylistic/eslint-plugin': 5.9.0(eslint@10.0.1(jiti@2.6.1)) - '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@vitest/eslint-plugin': 1.6.9(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@stylistic/eslint-plugin': 5.9.0(eslint@10.0.2(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@vitest/eslint-plugin': 1.6.9(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) ansis: 4.2.0 cac: 6.7.14 - eslint: 10.0.1(jiti@2.6.1) - eslint-config-flat-gitignore: 2.2.1(eslint@10.0.1(jiti@2.6.1)) + eslint: 10.0.2(jiti@2.6.1) + eslint-config-flat-gitignore: 2.2.1(eslint@10.0.2(jiti@2.6.1)) eslint-flat-config-utils: 2.1.4 - eslint-merge-processors: 2.0.0(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-antfu: 3.2.2(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-command: 3.4.0(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-import-lite: 0.3.1(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-jsdoc: 61.7.1(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-jsonc: 2.21.1(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-n: 17.24.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + eslint-merge-processors: 2.0.0(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-antfu: 3.2.2(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-import-lite: 0.3.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-jsdoc: 61.7.1(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-jsonc: 2.21.1(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-no-only-tests: 3.3.0 - eslint-plugin-perfectionist: 4.15.1(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-pnpm: 1.5.0(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-regexp: 2.10.0(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-toml: 0.12.0(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-unicorn: 62.0.0(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-vue: 10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))) - eslint-plugin-yml: 1.19.1(eslint@10.0.1(jiti@2.6.1)) - eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.26)(eslint@10.0.1(jiti@2.6.1)) + eslint-plugin-perfectionist: 4.15.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-pnpm: 1.6.0(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-regexp: 2.10.0(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-toml: 0.12.0(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-unicorn: 62.0.0(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-vue: 10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))) + eslint-plugin-yml: 1.19.1(eslint@10.0.2(jiti@2.6.1)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.26)(eslint@10.0.2(jiti@2.6.1)) globals: 16.5.0 jsonc-eslint-parser: 2.4.2 local-pkg: 1.1.2 parse-gitignore: 2.0.0 toml-eslint-parser: 0.10.1 - vue-eslint-parser: 10.4.0(eslint@10.0.1(jiti@2.6.1)) + vue-eslint-parser: 10.4.0(eslint@10.0.2(jiti@2.6.1)) yaml-eslint-parser: 1.3.2 optionalDependencies: '@next/eslint-plugin-next': 16.1.0 - '@unocss/eslint-plugin': 66.5.10(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-format: 1.1.0(eslint@10.0.1(jiti@2.6.1)) + '@unocss/eslint-plugin': 66.5.10(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-format: 1.1.0(eslint@10.0.2(jiti@2.6.1)) transitivePeerDependencies: - '@eslint/json' + - '@typescript-eslint/rule-tester' + - '@typescript-eslint/typescript-estree' + - '@typescript-eslint/utils' - '@vue/compiler-sfc' - supports-color - typescript @@ -4946,11 +4967,19 @@ snapshots: '@es-joy/jsdoccomment@0.78.0': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/types': 8.56.1 comment-parser: 1.4.1 esquery: 1.7.0 jsdoc-type-pratt-parser: 7.0.0 + '@es-joy/jsdoccomment@0.84.0': + dependencies: + '@types/estree': 1.0.8 + '@typescript-eslint/types': 8.56.1 + comment-parser: 1.4.5 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.1.1 + '@es-joy/resolve.exports@1.2.0': {} '@esbuild/aix-ppc64@0.27.3': @@ -5031,30 +5060,30 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@10.0.1(jiti@2.6.1))': + '@eslint-community/eslint-plugin-eslint-comments@4.7.0(eslint@10.0.2(jiti@2.6.1))': dependencies: escape-string-regexp: 4.0.0 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) ignore: 7.0.5 - '@eslint-community/eslint-utils@4.9.1(eslint@10.0.1(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.2(jiti@2.6.1))': dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/compat@2.0.2(eslint@10.0.1(jiti@2.6.1))': + '@eslint/compat@2.0.2(eslint@10.0.2(jiti@2.6.1))': dependencies: '@eslint/core': 1.1.0 optionalDependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) '@eslint/config-array@0.23.2': dependencies: '@eslint/object-schema': 3.0.2 debug: 4.4.3 - minimatch: 10.2.2 + minimatch: 10.2.4 transitivePeerDependencies: - supports-color @@ -5109,7 +5138,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.0.0': + '@img/colour@1.1.0': optional: true '@img/sharp-darwin-arm64@0.34.5': @@ -5208,122 +5237,122 @@ snapshots: '@inquirer/ansi@2.0.3': {} - '@inquirer/checkbox@5.0.7(@types/node@25.3.0)': + '@inquirer/checkbox@5.1.0(@types/node@25.3.3)': dependencies: '@inquirer/ansi': 2.0.3 - '@inquirer/core': 11.1.4(@types/node@25.3.0) + '@inquirer/core': 11.1.5(@types/node@25.3.3) '@inquirer/figures': 2.0.3 - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/type': 4.0.3(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/confirm@6.0.7(@types/node@25.3.0)': + '@inquirer/confirm@6.0.8(@types/node@25.3.3)': dependencies: - '@inquirer/core': 11.1.4(@types/node@25.3.0) - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/core': 11.1.5(@types/node@25.3.3) + '@inquirer/type': 4.0.3(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/core@11.1.4(@types/node@25.3.0)': + '@inquirer/core@11.1.5(@types/node@25.3.3)': dependencies: '@inquirer/ansi': 2.0.3 '@inquirer/figures': 2.0.3 - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/type': 4.0.3(@types/node@25.3.3) cli-width: 4.1.0 fast-wrap-ansi: 0.2.0 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/editor@5.0.7(@types/node@25.3.0)': + '@inquirer/editor@5.0.8(@types/node@25.3.3)': dependencies: - '@inquirer/core': 11.1.4(@types/node@25.3.0) - '@inquirer/external-editor': 2.0.3(@types/node@25.3.0) - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/core': 11.1.5(@types/node@25.3.3) + '@inquirer/external-editor': 2.0.3(@types/node@25.3.3) + '@inquirer/type': 4.0.3(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/expand@5.0.7(@types/node@25.3.0)': + '@inquirer/expand@5.0.8(@types/node@25.3.3)': dependencies: - '@inquirer/core': 11.1.4(@types/node@25.3.0) - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/core': 11.1.5(@types/node@25.3.3) + '@inquirer/type': 4.0.3(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/external-editor@2.0.3(@types/node@25.3.0)': + '@inquirer/external-editor@2.0.3(@types/node@25.3.3)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 '@inquirer/figures@2.0.3': {} - '@inquirer/input@5.0.7(@types/node@25.3.0)': + '@inquirer/input@5.0.8(@types/node@25.3.3)': dependencies: - '@inquirer/core': 11.1.4(@types/node@25.3.0) - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/core': 11.1.5(@types/node@25.3.3) + '@inquirer/type': 4.0.3(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/number@4.0.7(@types/node@25.3.0)': + '@inquirer/number@4.0.8(@types/node@25.3.3)': dependencies: - '@inquirer/core': 11.1.4(@types/node@25.3.0) - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/core': 11.1.5(@types/node@25.3.3) + '@inquirer/type': 4.0.3(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/password@5.0.7(@types/node@25.3.0)': + '@inquirer/password@5.0.8(@types/node@25.3.3)': dependencies: '@inquirer/ansi': 2.0.3 - '@inquirer/core': 11.1.4(@types/node@25.3.0) - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/core': 11.1.5(@types/node@25.3.3) + '@inquirer/type': 4.0.3(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 - - '@inquirer/prompts@8.2.1(@types/node@25.3.0)': - dependencies: - '@inquirer/checkbox': 5.0.7(@types/node@25.3.0) - '@inquirer/confirm': 6.0.7(@types/node@25.3.0) - '@inquirer/editor': 5.0.7(@types/node@25.3.0) - '@inquirer/expand': 5.0.7(@types/node@25.3.0) - '@inquirer/input': 5.0.7(@types/node@25.3.0) - '@inquirer/number': 4.0.7(@types/node@25.3.0) - '@inquirer/password': 5.0.7(@types/node@25.3.0) - '@inquirer/rawlist': 5.2.3(@types/node@25.3.0) - '@inquirer/search': 4.1.3(@types/node@25.3.0) - '@inquirer/select': 5.0.7(@types/node@25.3.0) + '@types/node': 25.3.3 + + '@inquirer/prompts@8.3.0(@types/node@25.3.3)': + dependencies: + '@inquirer/checkbox': 5.1.0(@types/node@25.3.3) + '@inquirer/confirm': 6.0.8(@types/node@25.3.3) + '@inquirer/editor': 5.0.8(@types/node@25.3.3) + '@inquirer/expand': 5.0.8(@types/node@25.3.3) + '@inquirer/input': 5.0.8(@types/node@25.3.3) + '@inquirer/number': 4.0.8(@types/node@25.3.3) + '@inquirer/password': 5.0.8(@types/node@25.3.3) + '@inquirer/rawlist': 5.2.4(@types/node@25.3.3) + '@inquirer/search': 4.1.4(@types/node@25.3.3) + '@inquirer/select': 5.1.0(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/rawlist@5.2.3(@types/node@25.3.0)': + '@inquirer/rawlist@5.2.4(@types/node@25.3.3)': dependencies: - '@inquirer/core': 11.1.4(@types/node@25.3.0) - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/core': 11.1.5(@types/node@25.3.3) + '@inquirer/type': 4.0.3(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/search@4.1.3(@types/node@25.3.0)': + '@inquirer/search@4.1.4(@types/node@25.3.3)': dependencies: - '@inquirer/core': 11.1.4(@types/node@25.3.0) + '@inquirer/core': 11.1.5(@types/node@25.3.3) '@inquirer/figures': 2.0.3 - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/type': 4.0.3(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/select@5.0.7(@types/node@25.3.0)': + '@inquirer/select@5.1.0(@types/node@25.3.3)': dependencies: '@inquirer/ansi': 2.0.3 - '@inquirer/core': 11.1.4(@types/node@25.3.0) + '@inquirer/core': 11.1.5(@types/node@25.3.3) '@inquirer/figures': 2.0.3 - '@inquirer/type': 4.0.3(@types/node@25.3.0) + '@inquirer/type': 4.0.3(@types/node@25.3.3) optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 - '@inquirer/type@4.0.3(@types/node@25.3.0)': + '@inquirer/type@4.0.3(@types/node@25.3.3)': optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -5361,9 +5390,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@napi-rs/cli@3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.0)': + '@napi-rs/cli@3.5.1(@emnapi/runtime@1.8.1)(@types/node@25.3.3)': dependencies: - '@inquirer/prompts': 8.2.1(@types/node@25.3.0) + '@inquirer/prompts': 8.3.0(@types/node@25.3.3) '@napi-rs/cross-toolchain': 1.0.3 '@napi-rs/wasm-tools': 1.0.1 '@octokit/rest': 22.0.1 @@ -5703,7 +5732,7 @@ snapshots: '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 fast-content-type-parse: 3.0.0 - json-with-bigint: 3.5.3 + json-with-bigint: 3.5.7 universal-user-agent: 7.0.3 '@octokit/rest@22.0.1': @@ -5717,7 +5746,7 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 - '@oxc-project/types@0.112.0': {} + '@oxc-project/types@0.114.0': {} '@pkgr/core@0.2.9': {} @@ -5737,49 +5766,51 @@ snapshots: react: 19.2.4 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) - '@rolldown/binding-android-arm64@1.0.0-rc.3': + '@rolldown/binding-android-arm64@1.0.0-rc.5': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.3': + '@rolldown/binding-darwin-arm64@1.0.0-rc.5': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.3': + '@rolldown/binding-darwin-x64@1.0.0-rc.5': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.3': + '@rolldown/binding-freebsd-x64@1.0.0-rc.5': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.5': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.5': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.5': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.5': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.5': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.5': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.5': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.5': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.5': optional: true '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rolldown/pluginutils@1.0.0-rc.5': {} + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -5861,11 +5892,11 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1))': + '@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1))': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) - '@typescript-eslint/types': 8.56.0 - eslint: 10.0.1(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) + '@typescript-eslint/types': 8.56.1 + eslint: 10.0.2(jiti@2.6.1) eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 @@ -5875,81 +5906,81 @@ snapshots: dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.2.0': + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 jiti: 2.6.1 lightningcss: 1.31.1 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.0 + tailwindcss: 4.2.1 - '@tailwindcss/oxide-android-arm64@4.2.0': + '@tailwindcss/oxide-android-arm64@4.2.1': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.0': + '@tailwindcss/oxide-darwin-arm64@4.2.1': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.0': + '@tailwindcss/oxide-darwin-x64@4.2.1': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.0': + '@tailwindcss/oxide-freebsd-x64@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.0': + '@tailwindcss/oxide-linux-x64-musl@4.2.1': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.0': + '@tailwindcss/oxide-wasm32-wasi@4.2.1': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': optional: true - '@tailwindcss/oxide@4.2.0': + '@tailwindcss/oxide@4.2.1': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.0 - '@tailwindcss/oxide-darwin-arm64': 4.2.0 - '@tailwindcss/oxide-darwin-x64': 4.2.0 - '@tailwindcss/oxide-freebsd-x64': 4.2.0 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.0 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.0 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.0 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.0 - '@tailwindcss/oxide-linux-x64-musl': 4.2.0 - '@tailwindcss/oxide-wasm32-wasi': 4.2.0 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 - - '@tailwindcss/vite@4.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@tailwindcss/node': 4.2.0 - '@tailwindcss/oxide': 4.2.0 - tailwindcss: 4.2.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) '@tanstack/history@1.161.4': {} - '@tanstack/react-router@1.162.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.4 '@tanstack/react-store': 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.162.2 + '@tanstack/router-core': 1.163.3 isbot: 5.1.35 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -5963,7 +5994,7 @@ snapshots: react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) - '@tanstack/router-core@1.162.2': + '@tanstack/router-core@1.163.3': dependencies: '@tanstack/history': 1.161.4 '@tanstack/store': 0.9.1 @@ -5973,9 +6004,9 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-generator@1.162.2': + '@tanstack/router-generator@1.164.0': dependencies: - '@tanstack/router-core': 1.162.2 + '@tanstack/router-core': 1.163.3 '@tanstack/router-utils': 1.161.4 '@tanstack/virtual-file-routes': 1.161.4 prettier: 3.8.1 @@ -5986,7 +6017,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.162.2(@tanstack/react-router@1.162.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5994,16 +6025,16 @@ snapshots: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/router-core': 1.162.2 - '@tanstack/router-generator': 1.162.2 + '@tanstack/router-core': 1.163.3 + '@tanstack/router-generator': 1.164.0 '@tanstack/router-utils': 1.161.4 '@tanstack/virtual-file-routes': 1.161.4 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.162.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -6082,37 +6113,37 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 - '@truenine/eslint10-config@2026.10209.11105(d12526f81920c349c9fa664256242f8c)': + '@truenine/eslint10-config@2026.10209.11105(57cd6091d29b00e41b508df9848011ef)': dependencies: - '@antfu/eslint-config': 6.7.1(@next/eslint-plugin-next@16.1.0)(@unocss/eslint-plugin@66.5.10(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.26)(eslint-plugin-format@1.1.0(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@antfu/eslint-config': 6.7.1(@next/eslint-plugin-next@16.1.0)(@typescript-eslint/rule-tester@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@unocss/eslint-plugin@66.5.10(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.26)(eslint-plugin-format@1.1.0(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) '@eslint/js': 9.39.2 '@next/eslint-plugin-next': 16.1.0 - '@unocss/eslint-config': 66.5.10(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@vue/eslint-config-prettier': 10.2.0(eslint@10.0.1(jiti@2.6.1))(prettier@3.8.1) - '@vue/eslint-config-typescript': 14.6.0(eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) - eslint-plugin-format: 1.1.0(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1))(prettier@3.8.1) - eslint-plugin-vue: 10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))) + '@unocss/eslint-config': 66.5.10(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@vue/eslint-config-prettier': 10.2.0(eslint@10.0.2(jiti@2.6.1))(prettier@3.8.1) + '@vue/eslint-config-typescript': 14.6.0(eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) + eslint-plugin-format: 1.1.0(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1))(prettier@3.8.1) + eslint-plugin-vue: 10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))) prettier: 3.8.1 typescript: 5.9.3 - typescript-eslint: 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@truenine/eslint10-config@2026.10209.11105(e33592f3a3b831acb119d37088dcba13)': + '@truenine/eslint10-config@2026.10209.11105(e2101efea8a228b7e40cea5868468857)': dependencies: - '@antfu/eslint-config': 6.7.1(@next/eslint-plugin-next@16.1.0)(@unocss/eslint-plugin@66.5.10(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.26)(eslint-plugin-format@1.1.0(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@antfu/eslint-config': 6.7.1(@next/eslint-plugin-next@16.1.0)(@typescript-eslint/rule-tester@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@unocss/eslint-plugin@66.5.10(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.26)(eslint-plugin-format@1.1.0(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) '@eslint/js': 9.39.2 '@next/eslint-plugin-next': 16.1.0 - '@unocss/eslint-config': 66.5.10(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@vue/eslint-config-prettier': 10.2.0(eslint@10.0.1(jiti@2.6.1))(prettier@3.8.1) - '@vue/eslint-config-typescript': 14.6.0(eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) - eslint-plugin-format: 1.1.0(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1))(prettier@3.8.1) - eslint-plugin-vue: 10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))) + '@unocss/eslint-config': 66.5.10(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@vue/eslint-config-prettier': 10.2.0(eslint@10.0.2(jiti@2.6.1))(prettier@3.8.1) + '@vue/eslint-config-typescript': 14.6.0(eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) + eslint-plugin-format: 1.1.0(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1))(prettier@3.8.1) + eslint-plugin-vue: 10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))) prettier: 3.8.1 typescript: 5.9.3 - typescript-eslint: 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@tybys/wasm-util@0.10.1': dependencies: @@ -6186,7 +6217,7 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 25.3.0 + '@types/node': 25.3.3 '@types/hast@3.0.4': dependencies: @@ -6198,7 +6229,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 '@types/mdast@4.0.4': dependencies: @@ -6208,7 +6239,7 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@25.3.0': + '@types/node@25.3.3': dependencies: undici-types: 7.18.2 @@ -6231,15 +6262,15 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.50.0 - '@typescript-eslint/type-utils': 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.50.0 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -6247,15 +6278,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.0 - eslint: 10.0.1(jiti@2.6.1) + '@typescript-eslint/parser': 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 10.0.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -6263,85 +6294,99 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.50.0 '@typescript-eslint/types': 8.50.0 '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.50.0 debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color '@typescript-eslint/project-service@8.50.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) + '@typescript-eslint/types': 8.50.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color + '@typescript-eslint/rule-tester@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + ajv: 6.14.0 + eslint: 10.0.2(jiti@2.6.1) + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + - typescript + '@typescript-eslint/scope-manager@8.50.0': dependencies: '@typescript-eslint/types': 8.50.0 '@typescript-eslint/visitor-keys': 8.50.0 - '@typescript-eslint/scope-manager@8.56.0': + '@typescript-eslint/scope-manager@8.56.1': dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 '@typescript-eslint/tsconfig-utils@8.50.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.50.0 '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -6349,7 +6394,7 @@ snapshots: '@typescript-eslint/types@8.50.0': {} - '@typescript-eslint/types@8.56.0': {} + '@typescript-eslint/types@8.56.1': {} '@typescript-eslint/typescript-estree@8.50.0(typescript@5.9.3)': dependencies: @@ -6358,7 +6403,7 @@ snapshots: '@typescript-eslint/types': 8.50.0 '@typescript-eslint/visitor-keys': 8.50.0 debug: 4.4.3 - minimatch: 9.0.6 + minimatch: 9.0.9 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -6366,14 +6411,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - minimatch: 9.0.6 + minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -6381,24 +6426,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.50.0 '@typescript-eslint/types': 8.50.0 '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6408,9 +6453,9 @@ snapshots: '@typescript-eslint/types': 8.50.0 eslint-visitor-keys: 4.2.1 - '@typescript-eslint/visitor-keys@8.56.0': + '@typescript-eslint/visitor-keys@8.56.1': dependencies: - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 '@unocss/config@66.5.10': @@ -6420,17 +6465,17 @@ snapshots: '@unocss/core@66.5.10': {} - '@unocss/eslint-config@66.5.10(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@unocss/eslint-config@66.5.10(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@unocss/eslint-plugin': 66.5.10(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@unocss/eslint-plugin': 66.5.10(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint - supports-color - typescript - '@unocss/eslint-plugin@66.5.10(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@unocss/eslint-plugin@66.5.10(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@unocss/config': 66.5.10 '@unocss/core': 66.5.10 '@unocss/rule-utils': 66.5.10 @@ -6446,7 +6491,7 @@ snapshots: '@unocss/core': 66.5.10 magic-string: 0.30.21 - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -6454,15 +6499,15 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.11 + ast-v8-to-istanbul: 0.3.12 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 @@ -6470,16 +6515,16 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/eslint-plugin@1.6.9(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/eslint-plugin@1.6.9(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 - vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -6492,13 +6537,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -6552,36 +6597,36 @@ snapshots: '@vue/compiler-dom': 3.5.26 '@vue/shared': 3.5.26 - '@vue/eslint-config-prettier@10.2.0(eslint@10.0.1(jiti@2.6.1))(prettier@3.8.1)': + '@vue/eslint-config-prettier@10.2.0(eslint@10.0.2(jiti@2.6.1))(prettier@3.8.1)': dependencies: - eslint: 10.0.1(jiti@2.6.1) - eslint-config-prettier: 10.1.8(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1))(prettier@3.8.1) + eslint: 10.0.2(jiti@2.6.1) + eslint-config-prettier: 10.1.8(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1))(prettier@3.8.1) prettier: 3.8.1 transitivePeerDependencies: - '@types/eslint' - '@vue/eslint-config-typescript@14.6.0(eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@vue/eslint-config-typescript@14.6.0(eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) - eslint-plugin-vue: 10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) + eslint-plugin-vue: 10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))) fast-glob: 3.3.3 - typescript-eslint: 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - vue-eslint-parser: 10.4.0(eslint@10.0.1(jiti@2.6.1)) + typescript-eslint: 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@10.0.2(jiti@2.6.1)) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@vue/eslint-config-typescript@14.6.0(eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@vue/eslint-config-typescript@14.6.0(eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) - eslint-plugin-vue: 10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) + eslint-plugin-vue: 10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))) fast-glob: 3.3.3 - typescript-eslint: 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - vue-eslint-parser: 10.4.0(eslint@10.0.1(jiti@2.6.1)) + typescript-eslint: 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@10.0.2(jiti@2.6.1)) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -6627,7 +6672,7 @@ snapshots: dependencies: tslib: 2.8.1 - ast-v8-to-istanbul@0.3.11: + ast-v8-to-istanbul@0.3.12: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -6644,6 +6689,8 @@ snapshots: bail@2.0.2: {} + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.10.0: {} @@ -6656,7 +6703,11 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@5.0.3: + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 @@ -6667,7 +6718,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001772 + caniuse-lite: 1.0.30001775 electron-to-chromium: 1.5.302 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -6676,7 +6727,7 @@ snapshots: cac@6.7.14: {} - caniuse-lite@1.0.30001772: {} + caniuse-lite@1.0.30001775: {} ccount@2.0.1: {} @@ -6837,7 +6888,7 @@ snapshots: empathic@2.0.0: {} - enhanced-resolve@5.19.0: + enhanced-resolve@5.20.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 @@ -6885,80 +6936,83 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.5.1(eslint@10.0.1(jiti@2.6.1)): + eslint-compat-utils@0.5.1(eslint@10.0.2(jiti@2.6.1)): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) semver: 7.7.4 - eslint-compat-utils@0.6.5(eslint@10.0.1(jiti@2.6.1)): + eslint-compat-utils@0.6.5(eslint@10.0.2(jiti@2.6.1)): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) semver: 7.7.4 - eslint-config-flat-gitignore@2.2.1(eslint@10.0.1(jiti@2.6.1)): + eslint-config-flat-gitignore@2.2.1(eslint@10.0.2(jiti@2.6.1)): dependencies: - '@eslint/compat': 2.0.2(eslint@10.0.1(jiti@2.6.1)) - eslint: 10.0.1(jiti@2.6.1) + '@eslint/compat': 2.0.2(eslint@10.0.2(jiti@2.6.1)) + eslint: 10.0.2(jiti@2.6.1) - eslint-config-prettier@10.1.8(eslint@10.0.1(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) eslint-flat-config-utils@2.1.4: dependencies: pathe: 2.0.3 - eslint-formatting-reporter@0.0.0(eslint@10.0.1(jiti@2.6.1)): + eslint-formatting-reporter@0.0.0(eslint@10.0.2(jiti@2.6.1)): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) prettier-linter-helpers: 1.0.1 - eslint-json-compat-utils@0.2.2(eslint@10.0.1(jiti@2.6.1))(jsonc-eslint-parser@2.4.2): + eslint-json-compat-utils@0.2.2(eslint@10.0.2(jiti@2.6.1))(jsonc-eslint-parser@2.4.2): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) esquery: 1.7.0 jsonc-eslint-parser: 2.4.2 - eslint-merge-processors@2.0.0(eslint@10.0.1(jiti@2.6.1)): + eslint-merge-processors@2.0.0(eslint@10.0.2(jiti@2.6.1)): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) eslint-parser-plain@0.1.1: {} - eslint-plugin-antfu@3.2.2(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-antfu@3.2.2(eslint@10.0.2(jiti@2.6.1)): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) - eslint-plugin-command@3.4.0(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)): dependencies: - '@es-joy/jsdoccomment': 0.78.0 - eslint: 10.0.1(jiti@2.6.1) + '@es-joy/jsdoccomment': 0.84.0 + '@typescript-eslint/rule-tester': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) - eslint-plugin-es-x@7.8.0(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-es-x@7.8.0(eslint@10.0.2(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - eslint: 10.0.1(jiti@2.6.1) - eslint-compat-utils: 0.5.1(eslint@10.0.1(jiti@2.6.1)) + eslint: 10.0.2(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-format@1.1.0(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-format@1.1.0(eslint@10.0.2(jiti@2.6.1)): dependencies: '@dprint/formatter': 0.3.0 '@dprint/markdown': 0.17.8 '@dprint/toml': 0.6.4 - eslint: 10.0.1(jiti@2.6.1) - eslint-formatting-reporter: 0.0.0(eslint@10.0.1(jiti@2.6.1)) + eslint: 10.0.2(jiti@2.6.1) + eslint-formatting-reporter: 0.0.0(eslint@10.0.2(jiti@2.6.1)) eslint-parser-plain: 0.1.1 prettier: 3.8.1 synckit: 0.11.12 - eslint-plugin-import-lite@0.3.1(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-import-lite@0.3.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 - eslint-plugin-jsdoc@61.7.1(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-jsdoc@61.7.1(eslint@10.0.2(jiti@2.6.1)): dependencies: '@es-joy/jsdoccomment': 0.78.0 '@es-joy/resolve.exports': 1.2.0 @@ -6966,7 +7020,7 @@ snapshots: comment-parser: 1.4.1 debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) espree: 11.1.1 esquery: 1.7.0 html-entities: 2.6.0 @@ -6978,13 +7032,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-jsonc@2.21.1(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-jsonc@2.21.1(eslint@10.0.2(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) diff-sequences: 27.5.1 - eslint: 10.0.1(jiti@2.6.1) - eslint-compat-utils: 0.6.5(eslint@10.0.1(jiti@2.6.1)) - eslint-json-compat-utils: 0.2.2(eslint@10.0.1(jiti@2.6.1))(jsonc-eslint-parser@2.4.2) + eslint: 10.0.2(jiti@2.6.1) + eslint-compat-utils: 0.6.5(eslint@10.0.2(jiti@2.6.1)) + eslint-json-compat-utils: 0.2.2(eslint@10.0.2(jiti@2.6.1))(jsonc-eslint-parser@2.4.2) espree: 10.4.0 graphemer: 1.4.0 jsonc-eslint-parser: 2.4.2 @@ -6993,12 +7047,12 @@ snapshots: transitivePeerDependencies: - '@eslint/json' - eslint-plugin-n@17.24.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-n@17.24.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) - enhanced-resolve: 5.19.0 - eslint: 10.0.1(jiti@2.6.1) - eslint-plugin-es-x: 7.8.0(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) + enhanced-resolve: 5.20.0 + eslint: 10.0.2(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@10.0.2(jiti@2.6.1)) get-tsconfig: 4.13.6 globals: 15.15.0 globrex: 0.1.2 @@ -7010,67 +7064,67 @@ snapshots: eslint-plugin-no-only-tests@3.3.0: {} - eslint-plugin-perfectionist@4.15.1(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-perfectionist@4.15.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) natural-orderby: 5.0.0 transitivePeerDependencies: - supports-color - typescript - eslint-plugin-pnpm@1.5.0(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-pnpm@1.6.0(eslint@10.0.2(jiti@2.6.1)): dependencies: empathic: 2.0.0 - eslint: 10.0.1(jiti@2.6.1) - jsonc-eslint-parser: 2.4.2 + eslint: 10.0.2(jiti@2.6.1) + jsonc-eslint-parser: 3.1.0 pathe: 2.0.3 - pnpm-workspace-yaml: 1.5.0 + pnpm-workspace-yaml: 1.6.0 tinyglobby: 0.2.15 yaml: 2.8.2 yaml-eslint-parser: 2.0.0 - eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1))(prettier@3.8.1): + eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1))(prettier@3.8.1): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@10.0.1(jiti@2.6.1)) + eslint-config-prettier: 10.1.8(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-regexp@2.10.0(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-regexp@2.10.0(eslint@10.0.2(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 comment-parser: 1.4.5 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) jsdoc-type-pratt-parser: 4.8.0 refa: 0.12.1 regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-toml@0.12.0(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-toml@0.12.0(eslint@10.0.2(jiti@2.6.1)): dependencies: debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) - eslint-compat-utils: 0.6.5(eslint@10.0.1(jiti@2.6.1)) + eslint: 10.0.2(jiti@2.6.1) + eslint-compat-utils: 0.6.5(eslint@10.0.2(jiti@2.6.1)) lodash: 4.17.23 toml-eslint-parser: 0.10.1 transitivePeerDependencies: - supports-color - eslint-plugin-unicorn@62.0.0(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-unicorn@62.0.0(eslint@10.0.2(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) '@eslint/plugin-kit': 0.4.1 change-case: 5.4.4 ci-info: 4.4.0 clean-regexp: 1.0.0 core-js-compat: 3.48.0 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) esquery: 1.7.0 find-up-simple: 1.0.1 globals: 16.5.0 @@ -7083,56 +7137,56 @@ snapshots: semver: 7.7.4 strip-indent: 4.1.1 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))): + eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) - eslint: 10.0.1(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) + eslint: 10.0.2(jiti@2.6.1) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 semver: 7.7.4 - vue-eslint-parser: 10.4.0(eslint@10.0.1(jiti@2.6.1)) + vue-eslint-parser: 10.4.0(eslint@10.0.2(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@stylistic/eslint-plugin': 5.9.0(eslint@10.0.1(jiti@2.6.1)) - '@typescript-eslint/parser': 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@stylistic/eslint-plugin': 5.9.0(eslint@10.0.2(jiti@2.6.1)) + '@typescript-eslint/parser': 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.1(jiti@2.6.1)))(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1))): + eslint-plugin-vue@10.6.2(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) - eslint: 10.0.1(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) + eslint: 10.0.2(jiti@2.6.1) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 semver: 7.7.4 - vue-eslint-parser: 10.4.0(eslint@10.0.1(jiti@2.6.1)) + vue-eslint-parser: 10.4.0(eslint@10.0.2(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@stylistic/eslint-plugin': 5.9.0(eslint@10.0.1(jiti@2.6.1)) - '@typescript-eslint/parser': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@stylistic/eslint-plugin': 5.9.0(eslint@10.0.2(jiti@2.6.1)) + '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-yml@1.19.1(eslint@10.0.1(jiti@2.6.1)): + eslint-plugin-yml@1.19.1(eslint@10.0.2(jiti@2.6.1)): dependencies: debug: 4.4.3 diff-sequences: 27.5.1 escape-string-regexp: 4.0.0 - eslint: 10.0.1(jiti@2.6.1) - eslint-compat-utils: 0.6.5(eslint@10.0.1(jiti@2.6.1)) + eslint: 10.0.2(jiti@2.6.1) + eslint-compat-utils: 0.6.5(eslint@10.0.2(jiti@2.6.1)) natural-compare: 1.4.0 yaml-eslint-parser: 1.3.2 transitivePeerDependencies: - supports-color - eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.26)(eslint@10.0.1(jiti@2.6.1)): + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.26)(eslint@10.0.2(jiti@2.6.1)): dependencies: '@vue/compiler-sfc': 3.5.26 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) eslint-scope@9.1.1: dependencies: @@ -7147,9 +7201,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.0.1(jiti@2.6.1): + eslint@10.0.2(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.2 '@eslint/config-helpers': 0.5.2 @@ -7176,7 +7230,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.2 + minimatch: 10.2.4 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -7444,6 +7498,8 @@ snapshots: jsdoc-type-pratt-parser@7.0.0: {} + jsdoc-type-pratt-parser@7.1.1: {} + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -7454,7 +7510,7 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - json-with-bigint@3.5.3: {} + json-with-bigint@3.5.7: {} json5@2.2.3: {} @@ -7465,6 +7521,12 @@ snapshots: espree: 9.6.1 semver: 7.7.4 + jsonc-eslint-parser@3.1.0: + dependencies: + acorn: 8.16.0 + eslint-visitor-keys: 5.0.1 + semver: 7.7.4 + jsonc-parser@3.3.1: {} jsonfile@6.2.0: @@ -7545,6 +7607,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.merge@4.6.2: {} + lodash@4.17.23: {} longest-streak@3.1.0: {} @@ -8024,13 +8088,13 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - minimatch@10.2.2: + minimatch@10.2.4: dependencies: - brace-expansion: 5.0.3 + brace-expansion: 5.0.4 - minimatch@9.0.6: + minimatch@9.0.9: dependencies: - brace-expansion: 5.0.3 + brace-expansion: 2.0.2 mlly@1.8.0: dependencies: @@ -8059,7 +8123,7 @@ snapshots: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001772 + caniuse-lite: 1.0.30001775 postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -8168,7 +8232,7 @@ snapshots: pluralize@8.0.0: {} - pnpm-workspace-yaml@1.5.0: + pnpm-workspace-yaml@1.6.0: dependencies: yaml: 2.8.2 @@ -8337,7 +8401,7 @@ snapshots: reusify@1.1.0: {} - rolldown-plugin-dts@0.22.1(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.22.2(rolldown@1.0.0-rc.5)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.1 '@babel/helper-validator-identifier': 8.0.0-rc.1 @@ -8348,30 +8412,30 @@ snapshots: dts-resolver: 2.1.3 get-tsconfig: 4.13.6 obug: 2.1.1 - rolldown: 1.0.0-rc.3 + rolldown: 1.0.0-rc.5 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-rc.3: + rolldown@1.0.0-rc.5: dependencies: - '@oxc-project/types': 0.112.0 - '@rolldown/pluginutils': 1.0.0-rc.3 + '@oxc-project/types': 0.114.0 + '@rolldown/pluginutils': 1.0.0-rc.5 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.3 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.3 - '@rolldown/binding-darwin-x64': 1.0.0-rc.3 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.3 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.3 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.3 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.3 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.3 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.3 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 + '@rolldown/binding-android-arm64': 1.0.0-rc.5 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.5 + '@rolldown/binding-darwin-x64': 1.0.0-rc.5 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.5 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.5 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.5 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.5 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.5 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.5 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.5 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.5 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.5 rollup@4.59.0: dependencies: @@ -8430,7 +8494,7 @@ snapshots: sharp@0.34.5: dependencies: - '@img/colour': 1.0.0 + '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.7.4 optionalDependencies: @@ -8524,7 +8588,7 @@ snapshots: tailwind-merge@3.5.0: {} - tailwindcss@4.2.0: {} + tailwindcss@4.2.1: {} tapable@2.3.0: {} @@ -8569,7 +8633,7 @@ snapshots: picomatch: 4.0.3 typescript: 5.9.3 - tsdown@0.20.3(synckit@0.11.12)(typescript@5.9.3): + tsdown@0.21.0-beta.2(synckit@0.11.12)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -8579,14 +8643,14 @@ snapshots: import-without-cache: 0.2.5 obug: 2.1.1 picomatch: 4.0.3 - rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.1(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown: 1.0.0-rc.5 + rolldown-plugin-dts: 0.22.2(rolldown@1.0.0-rc.5)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig-core: 7.5.0 - unrun: 0.2.27(synckit@0.11.12) + unrun: 0.2.28(synckit@0.11.12) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -8605,32 +8669,32 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo-darwin-64@2.8.10: + turbo-darwin-64@2.8.12: optional: true - turbo-darwin-arm64@2.8.10: + turbo-darwin-arm64@2.8.12: optional: true - turbo-linux-64@2.8.10: + turbo-linux-64@2.8.12: optional: true - turbo-linux-arm64@2.8.10: + turbo-linux-arm64@2.8.12: optional: true - turbo-windows-64@2.8.10: + turbo-windows-64@2.8.12: optional: true - turbo-windows-arm64@2.8.10: + turbo-windows-arm64@2.8.12: optional: true - turbo@2.8.10: + turbo@2.8.12: optionalDependencies: - turbo-darwin-64: 2.8.10 - turbo-darwin-arm64: 2.8.10 - turbo-linux-64: 2.8.10 - turbo-linux-arm64: 2.8.10 - turbo-windows-64: 2.8.10 - turbo-windows-arm64: 2.8.10 + turbo-darwin-64: 2.8.12 + turbo-darwin-arm64: 2.8.12 + turbo-linux-64: 2.8.12 + turbo-linux-arm64: 2.8.12 + turbo-windows-64: 2.8.12 + turbo-windows-arm64: 2.8.12 tw-animate-css@1.4.0: {} @@ -8640,13 +8704,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.50.0(@typescript-eslint/parser@8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.50.0(@typescript-eslint/parser@8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) + '@typescript-eslint/utils': 8.50.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8714,9 +8778,9 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unrun@0.2.27(synckit@0.11.12): + unrun@0.2.28(synckit@0.11.12): dependencies: - rolldown: 1.0.0-rc.3 + rolldown: 1.0.0-rc.5 optionalDependencies: synckit: 0.11.12 @@ -8763,7 +8827,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -8772,17 +8836,17 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -8799,10 +8863,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 transitivePeerDependencies: - jiti - less @@ -8816,10 +8880,10 @@ snapshots: - tsx - yaml - vue-eslint-parser@10.4.0(eslint@10.0.1(jiti@2.6.1)): + vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1)): dependencies: debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) eslint-scope: 9.1.1 eslint-visitor-keys: 5.0.1 espree: 11.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d8d1531c..ce02d5b2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,6 @@ packages: - libraries/logger/npm/* - libraries/md-compiler/npm/* - libraries/config/npm/* - - libraries/init-bundle/npm/* - libraries/input-plugins/npm/* - gui - doc @@ -15,9 +14,9 @@ catalog: '@mdx-js/react': ^3.1.1 '@monaco-editor/react': ^4.7.0 '@next/mdx': ^16.1.6 - '@tailwindcss/vite': ^4.2.0 - '@tanstack/react-router': ^1.162.2 - '@tanstack/router-plugin': ^1.162.2 + '@tailwindcss/vite': ^4.2.1 + '@tanstack/react-router': ^1.163.3 + '@tanstack/router-plugin': ^1.164.0 '@tauri-apps/api': ^2.10.1 '@tauri-apps/cli': ^2.10.0 '@tauri-apps/plugin-shell': ^2.3.5 @@ -27,7 +26,7 @@ catalog: '@types/estree-jsx': ^1.0.5 '@types/fs-extra': ^11.0.4 '@types/mdast': ^4.0.4 - '@types/node': ^25.3.0 + '@types/node': ^25.3.3 '@types/picomatch': ^4.0.2 '@types/react': ^19.2.14 '@types/react-dom': ^19.2.3 @@ -35,7 +34,7 @@ catalog: '@vitest/coverage-v8': 4.0.18 class-variance-authority: ^0.7.1 clsx: ^2.1.1 - eslint: ^10.0.1 + eslint: ^10.0.2 fast-check: ^4.5.3 fast-glob: ^3.3.3 fs-extra: ^11.3.3 @@ -55,10 +54,10 @@ catalog: remark-parse: ^11.0.0 remark-stringify: ^11.0.0 tailwind-merge: ^3.5.0 - tailwindcss: ^4.2.0 - tsdown: 0.20.3 + tailwindcss: ^4.2.1 + tsdown: 0.21.0-beta.2 tsx: ^4.21.0 - turbo: ^2.8.10 + turbo: ^2.8.12 tw-animate-css: ^1.4.0 typescript: ^5.9.3 unified: ^11.0.5 diff --git a/scripts/build-native.ts b/scripts/build-native.ts index 4b30953a..0f810c00 100644 --- a/scripts/build-native.ts +++ b/scripts/build-native.ts @@ -6,7 +6,7 @@ import {dirname, join, resolve} from 'node:path' import process from 'node:process' import {fileURLToPath} from 'node:url' -const LIBRARIES = ['logger', 'md-compiler', 'config', 'init-bundle'] as const +const LIBRARIES = ['logger', 'md-compiler', 'config'] as const const __dirname = import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)) const root = resolve(__dirname, '..') diff --git a/scripts/copy-napi.ts b/scripts/copy-napi.ts index f7616dec..b9d660df 100644 --- a/scripts/copy-napi.ts +++ b/scripts/copy-napi.ts @@ -3,7 +3,7 @@ import {cpSync, existsSync, mkdirSync, readdirSync} from 'node:fs' import {join, resolve} from 'node:path' import process from 'node:process' -const LIBRARIES = ['logger', 'md-compiler', 'config', 'init-bundle'] as const +const LIBRARIES = ['logger', 'md-compiler', 'config'] as const const PLATFORM_MAP: Record = { 'win32-x64': 'win32-x64-msvc', @@ -53,5 +53,4 @@ if (copied > 0) { console.warn(' pnpm -F @truenine/logger run build:native') console.warn(' pnpm -F @truenine/md-compiler run build:native') console.warn(' pnpm -F @truenine/config run build:native') - console.warn(' pnpm -F @truenine/init-bundle run build:native') } From fa681b0e42593cdf66723588fe5d60d3dd86a381 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 03:26:38 +0800 Subject: [PATCH 02/30] =?UTF-8?q?refactor(plugin-output):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E8=BE=93=E5=87=BA=E6=8F=92=E4=BB=B6=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=B9=B6=E6=8F=90=E5=8F=96=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将多个输出插件中的重复逻辑提取到基类中,包括: 1. 添加 SkillFrontMatterOptions、RuleContentOptions 等类型定义 2. 实现通用的 buildSkillFrontMatter、buildRuleContent 等方法 3. 添加错误处理和文件写入的公共方法 4. 将私有方法改为受保护的可重写方法 --- .../GenericSkillsOutputPlugin.ts | 13 -- .../ClaudeCodeCLIOutputPlugin.ts | 6 +- .../plugin-cursor/CursorOutputPlugin.ts | 72 ++------ .../OpencodeCLIOutputPlugin.ts | 6 +- .../AbstractOutputPlugin.ts | 159 +++++++++++++++++- cli/src/plugins/plugin-output-shared/index.ts | 6 +- .../QoderIDEPluginOutputPlugin.ts | 8 +- .../plugin-windsurf/WindsurfOutputPlugin.ts | 12 +- 8 files changed, 195 insertions(+), 87 deletions(-) diff --git a/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts index 2899d354..be3082ee 100644 --- a/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts +++ b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts @@ -277,19 +277,6 @@ export class GenericSkillsOutputPlugin extends AbstractOutputPlugin { 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, diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts index 050b9145..baaab994 100644 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts @@ -30,11 +30,11 @@ export class ClaudeCodeCLIOutputPlugin extends BaseCLIOutputPlugin { }) } - private buildRuleFileName(rule: RulePrompt): string { - return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` + protected override buildRuleFileName(rule: RulePrompt, prefix: string = RULE_FILE_PREFIX): string { + return `${prefix}${rule.series}-${rule.ruleName}.md` } - private buildRuleContent(rule: RulePrompt): string { + protected override buildRuleContent(rule: RulePrompt): string { if (rule.globs.length === 0) return rule.content return buildMarkdownWithFrontMatter({paths: rule.globs.map(doubleQuoted)}, rule.content) } diff --git a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts index 4c65e6ff..8451ad85 100644 --- a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts +++ b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts @@ -2,7 +2,6 @@ import type { FastCommandPrompt, OutputPluginContext, OutputWriteContext, - Project, RulePrompt, SkillPrompt, WriteResult, @@ -281,28 +280,16 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { return buildMarkdownWithFrontMatter({description: 'Global prompt (synced)', alwaysApply: true}, content) } - private async writeProjectGlobalRule(ctx: OutputWriteContext, project: Project, content: string): Promise { + private async writeProjectGlobalRule(ctx: OutputWriteContext, project: {dirFromWorkspacePath?: RelativePath | null}, 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} - } + return this.writeFileWithHandling(ctx, fullPath, content, { + type: 'globalRule', + relativePath + }) } private isPreservedSkill(name: string): boolean { return PRESERVED_SKILLS.has(name) } @@ -313,22 +300,14 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { 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 globalDir = this.getGlobalConfigDir() + const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(COMMANDS_SUBDIR, fileName), basePath: globalDir, 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} - } + return this.writeFileWithHandling(ctx, fullPath, content, { + type: 'globalFastCommand', + relativePath + }) } private async writeGlobalMcpConfig(ctx: OutputWriteContext, skills: readonly SkillPrompt[]): Promise { @@ -418,11 +397,6 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { 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) @@ -492,8 +466,6 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { } } - 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) @@ -510,24 +482,14 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { } private async writeRuleMdcFile(ctx: OutputWriteContext, rulesDir: string, rule: RulePrompt, basePath: string): Promise { - const fileName = this.buildRuleFileName(rule) + const fileName = this.buildRuleFileName(rule, RULE_FILE_PREFIX) 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} - } + + return this.writeFileWithHandling(ctx, fullPath, content, { + type: 'ruleFile', + relativePath + }) } } diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts index 4a70d190..348eea40 100644 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts @@ -381,11 +381,11 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { return normalized } - private buildRuleFileName(rule: RulePrompt): string { - return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` + protected override buildRuleFileName(rule: RulePrompt, prefix: string = RULE_FILE_PREFIX): string { + return `${prefix}${rule.series}-${rule.ruleName}.md` } - private buildRuleContent(rule: RulePrompt): string { + protected override buildRuleContent(rule: RulePrompt): string { if (rule.globs.length === 0) return rule.content return this.buildMarkdownContent(rule.content, {globs: [...rule.globs]}) } diff --git a/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts b/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts index 59f3572e..a7692193 100644 --- a/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts +++ b/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts @@ -1,4 +1,4 @@ -import type {CleanEffectHandler, EffectRegistration, EffectResult, FastCommandPrompt, ILogger, OutputCleanContext, OutputPlugin, OutputPluginContext, OutputWriteContext, Project, RegistryOperationResult, RulePrompt, RuleScope, WriteEffectHandler, WriteResult, WriteResults} from '@truenine/plugin-shared' +import type {CleanEffectHandler, EffectRegistration, EffectResult, FastCommandPrompt, ILogger, OutputCleanContext, OutputPlugin, OutputPluginContext, OutputWriteContext, Project, RegistryOperationResult, RulePrompt, RuleScope, SkillPrompt, 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' @@ -24,6 +24,46 @@ import { PluginKind } from '@truenine/plugin-shared' +/** + * Options for building skill front matter + */ +export interface SkillFrontMatterOptions { + readonly includeTools?: boolean + readonly toolFormat?: 'array' | 'string' + readonly additionalFields?: Record +} + +/** + * Options for building rule content + */ +export interface RuleContentOptions { + readonly fileExtension: '.mdc' | '.md' + readonly alwaysApply: boolean + readonly globJoinPattern: ', ' | '|' | string + readonly frontMatterFormatter?: (globs: string) => unknown + readonly additionalFrontMatter?: Record +} + +/** + * Options for executing write operations with dry-run support + */ +export interface WriteOperationOptions { + readonly ctx: OutputWriteContext + readonly type: string + readonly fullPath: string + readonly relativePath: RelativePath + readonly label?: string | undefined +} + +/** + * Context for error handling + */ +export interface ErrorContext { + readonly action: string + readonly path?: string + readonly [key: string]: unknown +} + /** * Options for transforming fast command names in output filenames. * Used by transformFastCommandName method to control prefix handling. @@ -540,4 +580,121 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin( + options: WriteOperationOptions, + execute: () => Promise + ): Promise { + const {ctx, type, fullPath, relativePath, label} = options + + if (ctx.dryRun === true) { // Handle dry-run mode + this.log.trace({action: 'dryRun', type, path: fullPath, label}) + return {path: relativePath, success: true, skipped: false} + } + + try { // Execute with standardized error handling + const result = await execute() + this.log.trace({action: 'write', type, path: fullPath, label}) + return result + } catch (error) { + return {...this.handleError(error, {action: 'write', type, path: fullPath, label}), path: relativePath} + } + } + + protected buildSkillFrontMatter( + skill: SkillPrompt, + options?: SkillFrontMatterOptions + ): Record { + const fm = skill.yamlFrontMatter + const result: Record = { + name: fm.name, + description: fm.description + } + + if ('displayName' in fm && fm.displayName != null) { // Conditionally add optional fields + result['displayName'] = fm.displayName + } + if ('keywords' in fm && fm.keywords != null && fm.keywords.length > 0) result['keywords'] = fm.keywords + if ('author' in fm && fm.author != null) result['author'] = fm.author + if ('version' in fm && fm.version != null) result['version'] = fm.version + + const includeTools = options?.includeTools ?? true // Handle tools based on options + if (includeTools && 'allowTools' in fm && fm.allowTools != null && fm.allowTools.length > 0) { + const toolFormat = options?.toolFormat ?? 'array' + result['allowTools'] = toolFormat === 'string' ? fm.allowTools.join(',') : fm.allowTools + } + + if (options?.additionalFields != null) { // Add any additional custom fields + Object.assign(result, options.additionalFields) + } + + return result + } + + protected buildRuleContent( + rule: RulePrompt, + options: RuleContentOptions + ): string { + const globsFormatted = rule.globs.length > 0 + ? rule.globs.join(options.globJoinPattern) + : '' + + const fmData: Record = { + alwaysApply: options.alwaysApply, + globs: options.frontMatterFormatter + ? options.frontMatterFormatter(globsFormatted) + : globsFormatted, + ...options.additionalFrontMatter + } + + return buildMarkdownWithFrontMatter(fmData, rule.content) + } + + protected buildRuleFileName( + rule: RulePrompt, + prefix: string = 'rule-' + ): string { + return `${prefix}${rule.series}-${rule.ruleName}.mdc` + } + + protected async writeFileWithHandling( + ctx: OutputWriteContext, + fullPath: string, + content: string, + options: { + type: string + label?: string + relativePath: RelativePath + } + ): Promise { + const result = await this.executeWriteOperation( + { + ctx, + type: options.type, + fullPath, + relativePath: options.relativePath, + label: options.label + }, + async () => { + this.ensureDirectory(path.dirname(fullPath)) + this.writeFileSync(fullPath, content) + return {path: options.relativePath, success: true as const} + } + ) + + if ('success' in result && !result.success) { // If executeWriteOperation returned a WriteResult (error case), pass it through + return result + } + + return {path: options.relativePath, success: true} + } } diff --git a/cli/src/plugins/plugin-output-shared/index.ts b/cli/src/plugins/plugin-output-shared/index.ts index a93e9de3..802b5a1e 100644 --- a/cli/src/plugins/plugin-output-shared/index.ts +++ b/cli/src/plugins/plugin-output-shared/index.ts @@ -4,7 +4,11 @@ export { export type { AbstractOutputPluginOptions, CombineOptions, - FastCommandNameTransformOptions + ErrorContext, + FastCommandNameTransformOptions, + RuleContentOptions, + SkillFrontMatterOptions, + WriteOperationOptions } from './AbstractOutputPlugin' export { BaseCLIOutputPlugin diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts index e7183c22..3c3a8e52 100644 --- a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts +++ b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts @@ -381,7 +381,7 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { return results } - private buildSkillFrontMatter(skill: SkillPrompt): Record { + protected override buildSkillFrontMatter(skill: SkillPrompt): Record { const fm = skill.yamlFrontMatter return { name: fm.name, @@ -406,11 +406,11 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { } } - private buildRuleFileName(rule: RulePrompt): string { - return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` + protected override buildRuleFileName(rule: RulePrompt, prefix: string = RULE_FILE_PREFIX): string { + return `${prefix}${rule.series}-${rule.ruleName}.md` } - private buildRuleContent(rule: RulePrompt): string { + protected override buildRuleContent(rule: RulePrompt): string { const fmData: Record = { trigger: TRIGGER_GLOB, [RULE_GLOB_KEY]: rule.globs.length > 0 ? rule.globs.join(', ') : '**/*', diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts index 0e140e9f..2a3ce4f0 100644 --- a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts +++ b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts @@ -1,3 +1,4 @@ +import type {RuleContentOptions} from '@truenine/plugin-output-shared' import type { FastCommandPrompt, OutputPluginContext, @@ -302,11 +303,6 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { 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) @@ -348,9 +344,11 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { } } - private buildRuleFileName(rule: RulePrompt): string { return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.md` } + protected override buildRuleFileName(rule: RulePrompt, prefix: string = RULE_FILE_PREFIX): string { + return `${prefix}${rule.series}-${rule.ruleName}.md` + } - private buildRuleContent(rule: RulePrompt): string { + protected override buildRuleContent(rule: RulePrompt, _options?: RuleContentOptions): 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') From 8cf3ccc2d8368030086322e49822b0b11bc64389 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 03:40:09 +0800 Subject: [PATCH 03/30] =?UTF-8?q?refactor(plugin-output-shared):=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E7=B3=BB=E5=88=97=E8=BF=87=E6=BB=A4=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=88=B0=E7=BB=9F=E4=B8=80=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将多个类型特定的过滤函数合并为一个通用的filterByProjectConfig函数 移除旧有的commandFilter、skillFilter和subAgentFilter文件 更新相关导入和测试引用 --- .../utils/commandFilter.ts | 11 --- .../plugin-output-shared/utils/filters.ts | 71 +++++++++++++++++++ .../plugin-output-shared/utils/index.ts | 17 +++-- .../plugin-output-shared/utils/ruleFilter.ts | 13 +--- .../plugin-output-shared/utils/skillFilter.ts | 11 --- .../utils/subAgentFilter.ts | 11 --- .../typeSpecificFilters.property.test.ts | 10 +-- 7 files changed, 88 insertions(+), 56 deletions(-) delete mode 100644 cli/src/plugins/plugin-output-shared/utils/commandFilter.ts create mode 100644 cli/src/plugins/plugin-output-shared/utils/filters.ts delete mode 100644 cli/src/plugins/plugin-output-shared/utils/skillFilter.ts delete mode 100644 cli/src/plugins/plugin-output-shared/utils/subAgentFilter.ts diff --git a/cli/src/plugins/plugin-output-shared/utils/commandFilter.ts b/cli/src/plugins/plugin-output-shared/utils/commandFilter.ts deleted file mode 100644 index f3446593..00000000 --- a/cli/src/plugins/plugin-output-shared/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/cli/src/plugins/plugin-output-shared/utils/filters.ts b/cli/src/plugins/plugin-output-shared/utils/filters.ts new file mode 100644 index 00000000..abef3c81 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/filters.ts @@ -0,0 +1,71 @@ +import type {FastCommandPrompt, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' +import type {ProjectConfig} from '@truenine/plugin-shared/types' +import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' + +/** + * Interface for items that can be filtered by series name + */ +export interface SeriesFilterable { + readonly seriName?: string | string[] | null +} + +/** + * Configuration path types for project config lookup + */ +export type FilterConfigPath = 'commands' | 'skills' | 'subAgents' | 'rules' + +export function filterByProjectConfig( + items: readonly T[], + projectConfig: ProjectConfig | undefined, + configPath: FilterConfigPath +): readonly T[] { + const effectiveSeries = resolveEffectiveIncludeSeries( + projectConfig?.includeSeries, + projectConfig?.[configPath]?.includeSeries + ) + return items.filter(item => matchesSeries(item.seriName, effectiveSeries)) +} + +/** + * Filter fast commands by project configuration + * @deprecated Use filterByProjectConfig(commands, config, 'commands') instead + */ +export function filterCommandsByProjectConfig( + commands: readonly FastCommandPrompt[], + projectConfig: ProjectConfig | undefined +): readonly FastCommandPrompt[] { + return filterByProjectConfig(commands, projectConfig, 'commands') +} + +/** + * Filter skills by project configuration + * @deprecated Use filterByProjectConfig(skills, config, 'skills') instead + */ +export function filterSkillsByProjectConfig( + skills: readonly SkillPrompt[], + projectConfig: ProjectConfig | undefined +): readonly SkillPrompt[] { + return filterByProjectConfig(skills, projectConfig, 'skills') +} + +/** + * Filter sub-agents by project configuration + * @deprecated Use filterByProjectConfig(subAgents, config, 'subAgents') instead + */ +export function filterSubAgentsByProjectConfig( + subAgents: readonly SubAgentPrompt[], + projectConfig: ProjectConfig | undefined +): readonly SubAgentPrompt[] { + return filterByProjectConfig(subAgents, projectConfig, 'subAgents') +} + +/** + * Filter rules by project configuration + * @deprecated Use filterByProjectConfig(rules, config, 'rules') instead + */ +export function filterRulesByProjectConfig( + rules: readonly RulePrompt[], + projectConfig: ProjectConfig | undefined +): readonly RulePrompt[] { + return filterByProjectConfig(rules, projectConfig, 'rules') +} diff --git a/cli/src/plugins/plugin-output-shared/utils/index.ts b/cli/src/plugins/plugin-output-shared/utils/index.ts index 0fc6db46..ba7639ed 100644 --- a/cli/src/plugins/plugin-output-shared/utils/index.ts +++ b/cli/src/plugins/plugin-output-shared/utils/index.ts @@ -1,6 +1,12 @@ export { - filterCommandsByProjectConfig -} from './commandFilter' + filterByProjectConfig, + filterCommandsByProjectConfig, + type FilterConfigPath, + filterRulesByProjectConfig, + filterSkillsByProjectConfig, + filterSubAgentsByProjectConfig, + type SeriesFilterable +} from './filters' export { findAllGitRepos, findGitModuleInfoDirs, @@ -8,7 +14,6 @@ export { } from './gitUtils' export { applySubSeriesGlobPrefix, - filterRulesByProjectConfig, getGlobalRules, getProjectRules } from './ruleFilter' @@ -17,9 +22,3 @@ export { resolveEffectiveIncludeSeries, resolveSubSeries } from './seriesFilter' -export { - filterSkillsByProjectConfig -} from './skillFilter' -export { - filterSubAgentsByProjectConfig -} from './subAgentFilter' diff --git a/cli/src/plugins/plugin-output-shared/utils/ruleFilter.ts b/cli/src/plugins/plugin-output-shared/utils/ruleFilter.ts index 117e2ea0..2ab224d6 100644 --- a/cli/src/plugins/plugin-output-shared/utils/ruleFilter.ts +++ b/cli/src/plugins/plugin-output-shared/utils/ruleFilter.ts @@ -1,6 +1,7 @@ import type {RulePrompt} from '@truenine/plugin-shared' import type {Project, ProjectConfig} from '@truenine/plugin-shared/types' -import {matchesSeries, resolveEffectiveIncludeSeries, resolveSubSeries} from './seriesFilter' +import {filterByProjectConfig} from './filters' +import {resolveSubSeries} from './seriesFilter' export function normalizeSubdirPath(subdir: string): string { let normalized = subdir.replaceAll(/\.\/+/g, '') @@ -77,14 +78,6 @@ export function applySubSeriesGlobPrefix( }) } -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' } @@ -94,7 +87,7 @@ function normalizeRuleScope(rule: RulePrompt): string { */ 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) + return applySubSeriesGlobPrefix(filterByProjectConfig(projectRules, project.projectConfig, 'rules'), project.projectConfig) } /** diff --git a/cli/src/plugins/plugin-output-shared/utils/skillFilter.ts b/cli/src/plugins/plugin-output-shared/utils/skillFilter.ts deleted file mode 100644 index 6f09a457..00000000 --- a/cli/src/plugins/plugin-output-shared/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/cli/src/plugins/plugin-output-shared/utils/subAgentFilter.ts b/cli/src/plugins/plugin-output-shared/utils/subAgentFilter.ts deleted file mode 100644 index 204e5223..00000000 --- a/cli/src/plugins/plugin-output-shared/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/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts index 08bf5b0f..5fb1db31 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 @@ -4,11 +4,13 @@ import type {ProjectConfig} from '../../../plugin-shared/types' import * as fc from 'fast-check' import {describe, expect, it} from 'vitest' -import {filterCommandsByProjectConfig} from './commandFilter' -import {filterRulesByProjectConfig} from './ruleFilter' +import { + filterCommandsByProjectConfig, + filterRulesByProjectConfig, + filterSkillsByProjectConfig, + filterSubAgentsByProjectConfig +} from './filters' 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)) From 6ac30de2853bd58a5658b62f41fd44d13101a6f3 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 04:11:09 +0800 Subject: [PATCH 04/30] =?UTF-8?q?refactor(plugin-output-shared):=20?= =?UTF-8?q?=E9=9B=86=E4=B8=AD=E5=85=B1=E4=BA=AB=E5=B8=B8=E9=87=8F=E4=BB=A5?= =?UTF-8?q?=E6=8F=90=E9=AB=98=E5=8F=AF=E7=BB=B4=E6=8A=A4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将硬编码的字符串常量提取到共享的constants.ts文件中,减少重复代码并提高一致性。修改CursorOutputPlugin以使用这些共享常量。 --- .../plugin-cursor/CursorOutputPlugin.ts | 41 +++--- .../plugins/plugin-output-shared/constants.ts | 122 ++++++++++++++++++ cli/src/plugins/plugin-output-shared/index.ts | 11 ++ 3 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 cli/src/plugins/plugin-output-shared/constants.ts diff --git a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts index 8451ad85..8982842f 100644 --- a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts +++ b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts @@ -12,25 +12,30 @@ 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, applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' +import { + AbstractOutputPlugin, + applySubSeriesGlobPrefix, + filterCommandsByProjectConfig, + filterRulesByProjectConfig, + filterSkillsByProjectConfig, + GlobalConfigDirs, + IgnoreFiles, + OutputFileNames, + OutputPrefixes, + OutputSubdirectories, + PreservedSkills +} from '@truenine/plugin-output-shared' 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' -]) +const GLOBAL_CONFIG_DIR = GlobalConfigDirs.CURSOR // Constants for local use (consider moving to constants.ts if used by multiple plugins) +const MCP_CONFIG_FILE = OutputFileNames.MCP_CONFIG +const COMMANDS_SUBDIR = OutputSubdirectories.COMMANDS +const RULES_SUBDIR = OutputSubdirectories.RULES +const GLOBAL_RULE_FILE = OutputFileNames.CURSOR_GLOBAL_RULE +const SKILLS_CURSOR_SUBDIR = OutputSubdirectories.CURSOR_SKILLS +const SKILL_FILE_NAME = OutputFileNames.SKILL +const RULE_FILE_PREFIX = OutputPrefixes.RULE +const PRESERVED_SKILLS = PreservedSkills.CURSOR export class CursorOutputPlugin extends AbstractOutputPlugin { constructor() { @@ -38,7 +43,7 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { globalConfigDir: GLOBAL_CONFIG_DIR, outputFileName: '', dependsOn: [PLUGIN_NAMES.AgentsOutput], - indexignore: '.cursorignore' + indexignore: IgnoreFiles.CURSOR }) this.registerCleanEffect('mcp-config-cleanup', async ctx => { diff --git a/cli/src/plugins/plugin-output-shared/constants.ts b/cli/src/plugins/plugin-output-shared/constants.ts new file mode 100644 index 00000000..a665c4d9 --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/constants.ts @@ -0,0 +1,122 @@ +/** + * Constants for output plugins + * Centralizes hardcoded strings to improve maintainability and reduce duplication + */ + +/** + * File and directory names used across output plugins + */ +export const OutputFileNames = { + /** Default skill file name */ + SKILL: 'SKILL.md', + /** Cursor global rule file */ + CURSOR_GLOBAL_RULE: 'global.mdc', + /** Cursor project rule file */ + CURSOR_PROJECT_RULE: 'always.md', + /** MCP configuration file */ + MCP_CONFIG: 'mcp.json', + /** Claude Code project memory file */ + CLAUDE_MEMORY: 'CLAUDE.md', + /** Windsurf global rules file */ + WINDSURF_GLOBAL_RULE: 'global_rules.md' +} as const + +/** + * Prefixes used for file naming + */ +export const OutputPrefixes = { + /** Rule file prefix */ + RULE: 'rule-', + /** Child rule/glob prefix */ + CHILD_RULE: 'glob-' +} as const + +/** + * Subdirectory names used by output plugins + */ +export const OutputSubdirectories = { + /** Rules subdirectory */ + RULES: 'rules', + /** Commands subdirectory */ + COMMANDS: 'commands', + /** Skills subdirectory */ + SKILLS: 'skills', + /** Agents subdirectory */ + AGENTS: 'agents', + /** Cursor-specific skills subdirectory */ + CURSOR_SKILLS: 'skills-cursor' +} as const + +/** + * Front matter field names + */ +export const FrontMatterFields = { + /** Always apply flag */ + ALWAYS_APPLY: 'alwaysApply', + /** Globs pattern */ + GLOBS: 'globs', + /** Description field */ + DESCRIPTION: 'description', + /** Name field */ + NAME: 'name', + /** Trigger type */ + TRIGGER: 'trigger' +} as const + +/** + * File extensions + */ +export const FileExtensions = { + /** Markdown file */ + MD: '.md', + /** Markdown with cursor config */ + MDC: '.mdc', + /** MDX file */ + MDX: '.mdx', + /** JSON file */ + JSON: '.json' +} as const + +/** + * Global configuration directory names + */ +export const GlobalConfigDirs = { + /** Cursor config directory */ + CURSOR: '.cursor', + /** Claude Code config directory */ + CLAUDE: '.claude', + /** Windsurf/Codeium config directory */ + WINDSURF: '.codeium/windsurf', + /** Generic Windsurf rules directory */ + WINDSURF_RULES: '.windsurf' +} as const + +/** + * Ignore file names + */ +export const IgnoreFiles = { + /** Cursor ignore file */ + CURSOR: '.cursorignore', + /** Windsurf ignore file */ + WINDSURF: '.codeiumignore' +} as const + +/** + * Preserved skill names that should not be overwritten + */ +export const PreservedSkills = { + CURSOR: new Set([ + 'create-rule', + 'create-skill', + 'create-subagent', + 'migrate-to-skills', + 'update-cursor-settings' + ]) +} as const + +/** + * Tool preset identifiers + */ +export const ToolPresets = { + CLAUDE_CODE: 'claudeCode' +} as const diff --git a/cli/src/plugins/plugin-output-shared/index.ts b/cli/src/plugins/plugin-output-shared/index.ts index 802b5a1e..e77c0b67 100644 --- a/cli/src/plugins/plugin-output-shared/index.ts +++ b/cli/src/plugins/plugin-output-shared/index.ts @@ -16,6 +16,17 @@ export { export type { BaseCLIOutputPluginOptions } from './BaseCLIOutputPlugin' +export { + FileExtensions, + FrontMatterFields, + GlobalConfigDirs, + IgnoreFiles, + OutputFileNames, + OutputPrefixes, + OutputSubdirectories, + PreservedSkills, + ToolPresets +} from './constants' export { applySubSeriesGlobPrefix, filterCommandsByProjectConfig, From 55334e2dcd80ecbf57654cc3c2bbcf9afb5421dc Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 04:33:47 +0800 Subject: [PATCH 05/30] =?UTF-8?q?refactor(cli):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E4=BE=9D=E8=B5=96=E8=A7=A3=E6=9E=90=E5=92=8C?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构代码结构,将插件依赖解析、上下文合并和资源处理逻辑提取到独立模块 优化文件类型分类和资源处理逻辑,提高代码可维护性 --- cli/src/PluginPipeline.ts | 537 ++------------- cli/src/pipeline/CliArgumentParser.ts | 287 ++++++++ cli/src/pipeline/ContextMerger.ts | 270 ++++++++ cli/src/pipeline/PluginDependencyResolver.ts | 148 ++++ cli/src/pipeline/index.ts | 23 + .../ResourceProcessor.ts | 176 +++++ .../SkillInputPlugin.ts | 630 ++++++------------ .../config/fileTypes.ts | 199 ++++++ 8 files changed, 1342 insertions(+), 928 deletions(-) create mode 100644 cli/src/pipeline/CliArgumentParser.ts create mode 100644 cli/src/pipeline/ContextMerger.ts create mode 100644 cli/src/pipeline/PluginDependencyResolver.ts create mode 100644 cli/src/pipeline/index.ts create mode 100644 cli/src/plugins/plugin-input-agentskills/ResourceProcessor.ts create mode 100644 cli/src/plugins/plugin-input-agentskills/config/fileTypes.ts diff --git a/cli/src/PluginPipeline.ts b/cli/src/PluginPipeline.ts index ae3361eb..f66cd6dd 100644 --- a/cli/src/PluginPipeline.ts +++ b/cli/src/PluginPipeline.ts @@ -1,296 +1,50 @@ import type {MdxGlobalScope} from '@truenine/md-compiler/globals' -import type {CollectedInputContext, ILogger, InputPlugin, InputPluginContext, OutputCleanContext, OutputPlugin, OutputWriteContext, Plugin, PluginKind, PluginOptions, UserConfigFile} from '@truenine/plugin-shared' +import type {CollectedInputContext, ILogger, InputPlugin, InputPluginContext, OutputCleanContext, OutputPlugin, OutputWriteContext, PluginOptions, UserConfigFile} from '@truenine/plugin-shared' import type {Command, CommandContext} from '@/commands' import type {PipelineConfig} from '@/config' +import type {ParsedCliArgs} from '@/pipeline' import * as fs from 'node:fs' import * as path from 'node:path' import {GlobalScopeCollector, ScopePriority, ScopeRegistry} from '@truenine/plugin-input-shared' -import {CircularDependencyError, createLogger, MissingDependencyError, setGlobalLogLevel} from '@truenine/plugin-shared' +import {createLogger, setGlobalLogLevel} from '@truenine/plugin-shared' import glob from 'fast-glob' import { - CleanCommand, - ConfigCommand, - ConfigShowCommand, - DryRunCleanCommand, - DryRunOutputCommand, - ExecuteCommand, - HelpCommand, - InitCommand, - JsonOutputCommand, - OutdatedCommand, - PluginsCommand, - UnknownCommand, - VersionCommand -} from '@/commands' + buildDependencyGraph, + extractUserArgs, + mergeContexts, + parseArgs, + + resolveCommand, + resolveLogLevel, + topologicalSort, + validateDependencies +} from '@/pipeline' import {startupVersionCheck} from '@/versionCheck' -/** - * Valid subcommands for the CLI - */ -export type Subcommand = 'help' | 'version' | 'outdated' | 'init' | 'dry-run' | 'clean' | 'config' | 'plugins' - -/** - * Valid log levels for the CLI - */ -export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' - -/** - * Command line argument parsing result - */ -export interface ParsedCliArgs { - readonly subcommand: Subcommand | undefined - readonly helpFlag: boolean - readonly versionFlag: boolean - readonly dryRun: boolean - readonly jsonFlag: boolean - readonly showFlag: boolean - readonly logLevel: LogLevel | undefined - readonly logLevelFlags: readonly LogLevel[] - readonly setOption: readonly [key: string, value: string][] - readonly unknownCommand: string | undefined - readonly positional: readonly string[] - readonly unknown: readonly string[] -} - -/** - * Extract actual user arguments from argv - * Compatible with various execution scenarios: npx, node, tsx, direct execution, etc. - */ -function extractUserArgs(argv: readonly string[]): string[] { - const args = [...argv] - - const first = args[0] // Skip runtime path (node, bun, deno, etc.) - if (first != null && isRuntimeExecutable(first)) args.shift() - - const second = args[0] // Skip script path or npx package name - if (second != null && isScriptOrPackage(second)) args.shift() - - return args -} - -/** - * Determine if it is a runtime executable - */ -function isRuntimeExecutable(arg: string): boolean { - const runtimes = ['node', 'nodejs', 'bun', 'deno', 'tsx', 'ts-node', 'npx', 'pnpx', 'yarn', 'pnpm'] - const normalized = arg.toLowerCase().replaceAll('\\', '/') - return runtimes.some(rt => { - const pattern = new RegExp(`(?:^|/)${rt}(?:\\.exe|\\.cmd|\\.ps1)?$`, 'i') - return pattern.test(normalized) || normalized === rt - }) -} - -/** - * Determine if it is a script file or package name - */ -function isScriptOrPackage(arg: string): boolean { - if (/\.(?:m?[jt]s|cjs)$/.test(arg)) return true // Script file - if (/[/\\]/.test(arg) && !arg.startsWith('-')) return true // File path containing separators - return /^(?:@[\w-]+\/)?[\w-]+$/.test(arg) && !arg.startsWith('-') // npx executed package name (e.g. tnmsc, @truenine/memory-sync-cli) -} - -/** - * Valid subcommands set for quick lookup - */ -const VALID_SUBCOMMANDS: ReadonlySet = new Set(['help', 'version', 'outdated', 'init', 'dry-run', 'clean', 'config', 'plugins']) - -/** - * Log level flags mapping - */ -const LOG_LEVEL_FLAGS: ReadonlyMap = new Map([ - ['--trace', 'trace'], - ['--debug', 'debug'], - ['--info', 'info'], - ['--warn', 'warn'], - ['--error', 'error'] -]) - -/** - * Log level priority map (lower number = more verbose) - */ -const LOG_LEVEL_PRIORITY: ReadonlyMap = new Map([ - ['trace', 0], - ['debug', 1], - ['info', 2], - ['warn', 3], - ['error', 4] -]) +export type { + LogLevel, + ParsedCliArgs, + Subcommand +} from '@/pipeline' // Re-export types for backwards compatibility + +export { // Re-export functions for backwards compatibility + buildDependencyGraph, + extractUserArgs, + parseArgs, + resolveCommand, + resolveLogLevel, + topologicalSort, + validateDependencies +} from '@/pipeline' /** - * Resolve log level from parsed arguments. - * When multiple log level flags are provided, returns the most verbose level. - * Priority: trace > debug > info > warn > error + * Plugin Pipeline - Orchestrates plugin execution * - * @param args - Parsed CLI arguments - * @returns The resolved log level, or undefined if no log level flag was provided - */ -export function resolveLogLevel(args: ParsedCliArgs): LogLevel | undefined { - const {logLevelFlags} = args - - if (logLevelFlags.length === 0) return void 0 - - let mostVerbose: LogLevel = logLevelFlags[0]! // Find the most verbose level (lowest priority number) - let lowestPriority = LOG_LEVEL_PRIORITY.get(mostVerbose) ?? 4 - - for (const level of logLevelFlags) { - const priority = LOG_LEVEL_PRIORITY.get(level) ?? 4 - if (priority < lowestPriority) { - lowestPriority = priority - mostVerbose = level - } - } - - return mostVerbose -} - -export function resolveCommand(args: ParsedCliArgs): Command { - const {helpFlag, versionFlag, subcommand, dryRun, unknownCommand, setOption, positional, showFlag} = args - - if (versionFlag) return new VersionCommand() // Version flag takes highest priority - - if (helpFlag) return new HelpCommand() // Help flag takes priority - - if (unknownCommand != null) return new UnknownCommand(unknownCommand) // Unknown command handling - - if (subcommand === 'version') return new VersionCommand() // Version subcommand - - if (subcommand === 'help') return new HelpCommand() // Help subcommand - - if (subcommand === 'outdated') return new OutdatedCommand() // Outdated subcommand - - if (subcommand === 'init') return new InitCommand() // Init subcommand - - if (subcommand === 'dry-run') return new DryRunOutputCommand() // Dry-run subcommand - - if (subcommand === 'clean') { // Clean subcommand with optional dry-run flag - if (dryRun) return new DryRunCleanCommand() - return new CleanCommand() - } - - if (subcommand === 'plugins') return new PluginsCommand() // Plugins subcommand - - if (subcommand === 'config' && showFlag) return new ConfigShowCommand() // Config --show subcommand - - if (subcommand !== 'config' || setOption.length > 0) return new ExecuteCommand() // Config subcommand - - const parsedPositional: [key: string, value: string][] = [] - for (const arg of positional) { - const eqIndex = arg.indexOf('=') - if (eqIndex > 0) parsedPositional.push([arg.slice(0, eqIndex), arg.slice(eqIndex + 1)]) - } - return new ConfigCommand([...setOption, ...parsedPositional]) -} - -/** - * Parse command line arguments into structured result + * This class has been refactored to use modular components: + * - CliArgumentParser: CLI argument parsing (moved to @/pipeline) + * - PluginDependencyResolver: Dependency resolution (moved to @/pipeline) + * - ContextMerger: Context merging (moved to @/pipeline) */ -export function parseArgs(args: readonly string[]): ParsedCliArgs { - const result: { - subcommand: Subcommand | undefined - helpFlag: boolean - versionFlag: boolean - dryRun: boolean - jsonFlag: boolean - showFlag: boolean - logLevel: LogLevel | undefined - logLevelFlags: LogLevel[] - setOption: [key: string, value: string][] - unknownCommand: string | undefined - positional: string[] - unknown: string[] - } = { - subcommand: void 0, - helpFlag: false, - versionFlag: false, - dryRun: false, - jsonFlag: false, - showFlag: false, - logLevel: void 0, - logLevelFlags: [], - setOption: [], - unknownCommand: void 0, - positional: [], - unknown: [] - } - - let firstPositionalProcessed = false - - for (let i = 0; i < args.length; i++) { - const arg = args[i] - if (arg == null) continue - - if (arg === '--') { // Handle -- separator: all following args are positional - result.positional.push(...args.slice(i + 1).filter((a): a is string => a != null)) - break - } - - if (arg.startsWith('--')) { // Long options - const parts = arg.split('=') - const key = parts[0] ?? '' - - const logLevel = LOG_LEVEL_FLAGS.get(key) // Check log level flags - if (logLevel != null) { - result.logLevelFlags.push(logLevel) - result.logLevel = logLevel - continue - } - - switch (key) { - case '--help': result.helpFlag = true; break - case '--version': result.versionFlag = true; break - case '--dry-run': result.dryRun = true; break - case '--json': result.jsonFlag = true; break - case '--show': result.showFlag = true; break - case '--set': - if (parts.length > 1) { // Parse --set key=value from next arg or from = syntax - const keyValue = parts.slice(1).join('=') - const eqIndex = keyValue.indexOf('=') - if (eqIndex > 0) result.setOption.push([keyValue.slice(0, eqIndex), keyValue.slice(eqIndex + 1)]) - } else { - const nextArg = args[i + 1] // Next arg is the value - if (nextArg != null) { - const eqIndex = nextArg.indexOf('=') - if (eqIndex > 0) { - result.setOption.push([nextArg.slice(0, eqIndex), nextArg.slice(eqIndex + 1)]) - i++ // Skip next arg - } - } - } - break - default: result.unknown.push(arg) - } - continue - } - - if (arg.startsWith('-') && arg.length > 1) { // Short options - const flags = arg.slice(1) - for (const flag of flags) { - switch (flag) { - case 'h': result.helpFlag = true; break - case 'v': result.versionFlag = true; break - case 'n': result.dryRun = true; break - case 'j': result.jsonFlag = true; break - default: result.unknown.push(`-${flag}`) - } - } - continue - } - - if (!firstPositionalProcessed) { // First positional argument: check if it's a subcommand - firstPositionalProcessed = true - if (VALID_SUBCOMMANDS.has(arg)) result.subcommand = arg as Subcommand - else { - result.unknownCommand = arg // Unknown first positional is captured as unknownCommand - } - continue - } - - result.positional.push(arg) // Remaining positional arguments - } - - return result -} - export class PluginPipeline { private readonly logger: ILogger readonly args: ParsedCliArgs @@ -324,7 +78,7 @@ export class PluginPipeline { setGlobalLogLevel('silent') // Suppress all console logging in JSON mode const selfJsonCommands = new Set(['config-show', 'plugins']) // only need log suppression, not JsonOutputCommand wrapping // Commands that handle their own JSON output (config --show, plugins) - if (!selfJsonCommands.has(command.name)) command = new JsonOutputCommand(command) + if (!selfJsonCommands.has(command.name)) command = new (await import('@/commands')).JsonOutputCommand(command) } const commandCtx = this.createCommandContext(context, userConfigOptions) @@ -369,126 +123,16 @@ export class PluginPipeline { } } - buildDependencyGraph(plugins: readonly Plugin[]): Map { - const graph = new Map() - for (const plugin of plugins) { - const deps = plugin.dependsOn ?? [] - graph.set(plugin.name, [...deps]) - } - return graph - } - - validateDependencies(plugins: readonly Plugin[]): void { - const pluginNames = new Set(plugins.map(p => p.name)) - for (const plugin of plugins) { - const deps = plugin.dependsOn ?? [] - for (const dep of deps) { - if (!pluginNames.has(dep)) throw new MissingDependencyError(plugin.name, dep) - } - } + topologicalSort(plugins: readonly T[]): T[] { + return topologicalSort(plugins as unknown as Parameters[0]) as unknown as T[] // Delegate to the modular implementation } - topologicalSort(plugins: readonly Plugin[]): Plugin[] { - this.validateDependencies(plugins) // Validate dependencies first - - const pluginMap = new Map>() // Build plugin map for quick lookup - for (const plugin of plugins) pluginMap.set(plugin.name, plugin) - - const inDegree = new Map() // Build in-degree map (count of incoming edges) - for (const plugin of plugins) inDegree.set(plugin.name, 0) - - const dependents = new Map() // Build adjacency list (dependents for each plugin) - for (const plugin of plugins) dependents.set(plugin.name, []) - - for (const plugin of plugins) { // Populate in-degree and dependents - const deps = plugin.dependsOn ?? [] - for (const dep of deps) { - inDegree.set(plugin.name, (inDegree.get(plugin.name) ?? 0) + 1) // Increment in-degree for current plugin - const depList = dependents.get(dep) ?? [] // Add current plugin as dependent of dep - depList.push(plugin.name) - dependents.set(dep, depList) - } - } - - const queue: string[] = [] // Use registration order for initial queue // Initialize queue with plugins that have no dependencies (in-degree = 0) - for (const plugin of plugins) { - if (inDegree.get(plugin.name) === 0) queue.push(plugin.name) - } - - const result: Plugin[] = [] // Process queue - while (queue.length > 0) { - const current = queue.shift()! // Take first element to preserve registration order - const plugin = pluginMap.get(current)! - result.push(plugin) - - const currentDependents = dependents.get(current) ?? [] // Process dependents in registration order - const sortedDependents = currentDependents.sort((a, b) => { // Sort dependents by their original registration order - const indexA = plugins.findIndex(p => p.name === a) - const indexB = plugins.findIndex(p => p.name === b) - return indexA - indexB - }) - - for (const dependent of sortedDependents) { - const newDegree = (inDegree.get(dependent) ?? 0) - 1 - inDegree.set(dependent, newDegree) - if (newDegree === 0) queue.push(dependent) - } - } - - if (result.length === plugins.length) return result // Check for cycle: if not all plugins are in result, there's a cycle - - const cyclePath = this.findCyclePath(plugins, inDegree) - throw new CircularDependencyError(cyclePath) + buildDependencyGraph(plugins: readonly T[]): Map> { + return buildDependencyGraph(plugins as unknown as Parameters[0]) as unknown as Map> // Delegate to the modular implementation } - private findCyclePath( - plugins: readonly Plugin[], - inDegree: Map - ): string[] { - const cycleNodes = new Set() // Find nodes that are part of a cycle (in-degree > 0) - for (const [name, degree] of inDegree) { - if (degree > 0) cycleNodes.add(name) - } - - const deps = new Map() // Build dependency map for cycle nodes - for (const plugin of plugins) { - if (cycleNodes.has(plugin.name)) { - const pluginDeps = (plugin.dependsOn ?? []).filter(d => cycleNodes.has(d)) - deps.set(plugin.name, pluginDeps) - } - } - - const visited = new Set() // DFS to find cycle path - const path: string[] = [] - - const dfs = (node: string): boolean => { - if (path.includes(node)) { - path.push(node) // Found cycle, add closing node to complete the cycle - return true - } - if (visited.has(node)) return false - - visited.add(node) - path.push(node) - - for (const dep of deps.get(node) ?? []) { - if (dfs(dep)) return true - } - - path.pop() - return false - } - - for (const node of cycleNodes) { // Start DFS from any cycle node - if (dfs(node)) { - const cycleStart = path.indexOf(path.at(-1)!) // Extract just the cycle portion - return path.slice(cycleStart) - } - visited.clear() - path.length = 0 - } - - return [...cycleNodes] // Fallback: return all cycle nodes + validateDependencies(plugins: readonly T[]): void { + validateDependencies(plugins as unknown as Parameters[0]) // Delegate to the modular implementation } async executePluginsInOrder( @@ -499,7 +143,7 @@ export class PluginPipeline { ): Promise> { if (plugins.length === 0) return {} - const sortedPlugins = this.topologicalSort(plugins) as InputPlugin[] // Sort plugins by dependencies (cast is safe since InputPlugin extends Plugin) + const sortedPlugins = topologicalSort(plugins) as InputPlugin[] // Sort plugins by dependencies const globalScopeCollector = new GlobalScopeCollector({userConfig}) // Create GlobalScopeCollector and ScopeRegistry for MDX expression evaluation const globalScope: MdxGlobalScope = globalScopeCollector.collect() @@ -533,7 +177,7 @@ export class PluginPipeline { outputsByPlugin.set(plugin.name, output) // Store output for this plugin - accumulatedContext = this.mergeContexts(accumulatedContext, output) // Merge into accumulated context + accumulatedContext = mergeContexts(accumulatedContext, output) // Merge into accumulated context const inputPluginWithScopes = plugin as InputPlugin & {getRegisteredScopes?: () => readonly {namespace: string, values: Record}[]} // Collect registered scopes from plugin and register them to ScopeRegistry if (inputPluginWithScopes.getRegisteredScopes != null) { @@ -560,7 +204,7 @@ export class PluginPipeline { let merged: Partial = {} // Merge all dependency outputs for (const depName of allDeps) { const depOutput = outputsByPlugin.get(depName) - if (depOutput != null) merged = this.mergeContexts(merged, depOutput) + if (depOutput != null) merged = mergeContexts(merged, depOutput) } return merged @@ -578,7 +222,7 @@ export class PluginPipeline { if (visited.has(dep)) continue visited.add(dep) - const depOutput = outputsByPlugin.get(dep) // Since we've already executed it, we can look it up // We need to find the plugin to get its dependencies // Get the plugin's dependencies recursively + const depOutput = outputsByPlugin.get(dep) if (depOutput != null) result.push(dep) } } @@ -586,97 +230,4 @@ export class PluginPipeline { visit(plugin.dependsOn ?? []) return result } - - private mergeContexts( - base: Partial, - addition: Partial - ): Partial { - let {workspace} = base // Build merged workspace - if (addition.workspace != null) { - if (workspace != null) { - const projectMap = new Map() // Merge projects: later projects with same name replace earlier ones - for (const project of workspace.projects) projectMap.set(project.name, project) - for (const project of addition.workspace.projects) projectMap.set(project.name, project) - workspace = { - directory: addition.workspace.directory ?? workspace.directory, - projects: [...projectMap.values()] - } - } else { - ; ({workspace} = addition) - } - } - - const vscodeConfigFiles: CollectedInputContext['vscodeConfigFiles'] | undefined - = addition.vscodeConfigFiles != null - ? [...base.vscodeConfigFiles ?? [], ...addition.vscodeConfigFiles] - : base.vscodeConfigFiles - - const jetbrainsConfigFiles: CollectedInputContext['jetbrainsConfigFiles'] | undefined - = addition.jetbrainsConfigFiles != null - ? [...base.jetbrainsConfigFiles ?? [], ...addition.jetbrainsConfigFiles] - : base.jetbrainsConfigFiles - - const editorConfigFiles: CollectedInputContext['editorConfigFiles'] | undefined - = addition.editorConfigFiles != null - ? [...base.editorConfigFiles ?? [], ...addition.editorConfigFiles] - : base.editorConfigFiles - - const fastCommands: CollectedInputContext['fastCommands'] | undefined - = addition.fastCommands != null - ? [...base.fastCommands ?? [], ...addition.fastCommands] - : base.fastCommands - - const subAgents: CollectedInputContext['subAgents'] | undefined - = addition.subAgents != null - ? [...base.subAgents ?? [], ...addition.subAgents] - : base.subAgents - - const skills: CollectedInputContext['skills'] | undefined - = addition.skills != null - ? [...base.skills ?? [], ...addition.skills] - : base.skills - - const rules: CollectedInputContext['rules'] | undefined - = addition.rules != null - ? [...base.rules ?? [], ...addition.rules] - : base.rules - - const aiAgentIgnoreConfigFiles: CollectedInputContext['aiAgentIgnoreConfigFiles'] | undefined - = addition.aiAgentIgnoreConfigFiles != null - ? [...base.aiAgentIgnoreConfigFiles ?? [], ...addition.aiAgentIgnoreConfigFiles] - : base.aiAgentIgnoreConfigFiles - - const globalMemory: CollectedInputContext['globalMemory'] | undefined // globalMemory: last one wins - = addition.globalMemory ?? base.globalMemory - - const shadowSourceProjectDir: CollectedInputContext['shadowSourceProjectDir'] | undefined // shadowSourceProjectDir: last one wins - = addition.shadowSourceProjectDir ?? base.shadowSourceProjectDir - - const readmePrompts: CollectedInputContext['readmePrompts'] | undefined // readmePrompts: concatenate arrays - = addition.readmePrompts != null - ? [...base.readmePrompts ?? [], ...addition.readmePrompts] - : base.readmePrompts - - const globalGitIgnore: CollectedInputContext['globalGitIgnore'] | undefined // globalGitIgnore: last one wins - = addition.globalGitIgnore ?? base.globalGitIgnore - const shadowGitExclude: CollectedInputContext['shadowGitExclude'] | undefined // shadowGitExclude: last one wins (matches other scalar merges like globalGitIgnore) - = addition.shadowGitExclude ?? base.shadowGitExclude - - return { // Build result object using object literal - ...workspace != null ? {workspace} : {}, - ...vscodeConfigFiles != null ? {vscodeConfigFiles} : {}, - ...jetbrainsConfigFiles != null ? {jetbrainsConfigFiles} : {}, - ...editorConfigFiles != null ? {editorConfigFiles} : {}, - ...fastCommands != null ? {fastCommands} : {}, - ...subAgents != null ? {subAgents} : {}, - ...skills != null ? {skills} : {}, - ...rules != null ? {rules} : {}, - ...aiAgentIgnoreConfigFiles != null ? {aiAgentIgnoreConfigFiles} : {}, - ...globalMemory != null ? {globalMemory} : {}, - ...shadowSourceProjectDir != null ? {shadowSourceProjectDir} : {}, - ...readmePrompts != null ? {readmePrompts} : {}, - ...globalGitIgnore != null ? {globalGitIgnore} : {}, - ...shadowGitExclude != null ? {shadowGitExclude} : {} - } - } } diff --git a/cli/src/pipeline/CliArgumentParser.ts b/cli/src/pipeline/CliArgumentParser.ts new file mode 100644 index 00000000..907516f8 --- /dev/null +++ b/cli/src/pipeline/CliArgumentParser.ts @@ -0,0 +1,287 @@ +/** + * CLI Argument Parser Module + * Handles extraction and parsing of command-line arguments + */ + +import type {Command} from '@/commands' +import { + CleanCommand, + ConfigCommand, + ConfigShowCommand, + DryRunCleanCommand, + DryRunOutputCommand, + ExecuteCommand, + HelpCommand, + InitCommand, + OutdatedCommand, + PluginsCommand, + UnknownCommand, + VersionCommand +} from '@/commands' + +/** + * Valid subcommands for the CLI + */ +export type Subcommand = 'help' | 'version' | 'outdated' | 'init' | 'dry-run' | 'clean' | 'config' | 'plugins' + +/** + * Valid log levels for the CLI + */ +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' + +/** + * Command line argument parsing result + */ +export interface ParsedCliArgs { + readonly subcommand: Subcommand | undefined + readonly helpFlag: boolean + readonly versionFlag: boolean + readonly dryRun: boolean + readonly jsonFlag: boolean + readonly showFlag: boolean + readonly logLevel: LogLevel | undefined + readonly logLevelFlags: readonly LogLevel[] + readonly setOption: readonly [key: string, value: string][] + readonly unknownCommand: string | undefined + readonly positional: readonly string[] + readonly unknown: readonly string[] +} + +/** + * Valid subcommands set for quick lookup + */ +const VALID_SUBCOMMANDS: ReadonlySet = new Set(['help', 'version', 'outdated', 'init', 'dry-run', 'clean', 'config', 'plugins']) + +/** + * Log level flags mapping + */ +const LOG_LEVEL_FLAGS: ReadonlyMap = new Map([ + ['--trace', 'trace'], + ['--debug', 'debug'], + ['--info', 'info'], + ['--warn', 'warn'], + ['--error', 'error'] +]) + +/** + * Log level priority map (lower number = more verbose) + */ +const LOG_LEVEL_PRIORITY: ReadonlyMap = new Map([ + ['trace', 0], + ['debug', 1], + ['info', 2], + ['warn', 3], + ['error', 4] +]) + +/** + * Extract actual user arguments from argv + * Compatible with various execution scenarios: npx, node, tsx, direct execution, etc. + */ +export function extractUserArgs(argv: readonly string[]): string[] { + const args = [...argv] + + const first = args[0] // Skip runtime path (node, bun, deno, etc.) + if (first != null && isRuntimeExecutable(first)) args.shift() + + const second = args[0] // Skip script path or npx package name + if (second != null && isScriptOrPackage(second)) args.shift() + + return args +} + +/** + * Determine if it is a runtime executable + */ +function isRuntimeExecutable(arg: string): boolean { + const runtimes = ['node', 'nodejs', 'bun', 'deno', 'tsx', 'ts-node', 'npx', 'pnpx', 'yarn', 'pnpm'] + const normalized = arg.toLowerCase().replaceAll('\\', '/') + return runtimes.some(rt => { + const pattern = new RegExp(`(?:^|/)${rt}(?:\\.exe|\\.cmd|\\.ps1)?$`, 'i') + return pattern.test(normalized) || normalized === rt + }) +} + +/** + * Determine if it is a script file or package name + */ +function isScriptOrPackage(arg: string): boolean { + if (/\.(?:m?[jt]s|cjs)$/.test(arg)) return true // Script file + if (/[/\\]/.test(arg) && !arg.startsWith('-')) return true // File path containing separators + return /^(?:@[\w-]+\/)?[\w-]+$/.test(arg) && !arg.startsWith('-') // npx executed package name +} + +/** + * Resolve log level from parsed arguments. + * When multiple log level flags are provided, returns the most verbose level. + * Priority: trace > debug > info > warn > error + */ +export function resolveLogLevel(args: ParsedCliArgs): LogLevel | undefined { + const {logLevelFlags} = args + + if (logLevelFlags.length === 0) return void 0 + + let mostVerbose: LogLevel = logLevelFlags[0]! // Find the most verbose level (lowest priority number) + let lowestPriority = LOG_LEVEL_PRIORITY.get(mostVerbose) ?? 4 + + for (const level of logLevelFlags) { + const priority = LOG_LEVEL_PRIORITY.get(level) ?? 4 + if (priority < lowestPriority) { + lowestPriority = priority + mostVerbose = level + } + } + + return mostVerbose +} + +/** + * Parse command line arguments into structured result + */ +export function parseArgs(args: readonly string[]): ParsedCliArgs { + const result: { + subcommand: Subcommand | undefined + helpFlag: boolean + versionFlag: boolean + dryRun: boolean + jsonFlag: boolean + showFlag: boolean + logLevel: LogLevel | undefined + logLevelFlags: LogLevel[] + setOption: [key: string, value: string][] + unknownCommand: string | undefined + positional: string[] + unknown: string[] + } = { + subcommand: void 0, + helpFlag: false, + versionFlag: false, + dryRun: false, + jsonFlag: false, + showFlag: false, + logLevel: void 0, + logLevelFlags: [], + setOption: [], + unknownCommand: void 0, + positional: [], + unknown: [] + } + + let firstPositionalProcessed = false + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg == null) continue + + if (arg === '--') { // Handle -- separator: all following args are positional + result.positional.push(...args.slice(i + 1).filter((a): a is string => a != null)) + break + } + + if (arg.startsWith('--')) { // Long options + const parts = arg.split('=') + const key = parts[0] ?? '' + + const logLevel = LOG_LEVEL_FLAGS.get(key) // Check log level flags + if (logLevel != null) { + result.logLevelFlags.push(logLevel) + result.logLevel = logLevel + continue + } + + switch (key) { + case '--help': result.helpFlag = true; break + case '--version': result.versionFlag = true; break + case '--dry-run': result.dryRun = true; break + case '--json': result.jsonFlag = true; break + case '--show': result.showFlag = true; break + case '--set': + if (parts.length > 1) { // Parse --set key=value from next arg or from = syntax + const keyValue = parts.slice(1).join('=') + const eqIndex = keyValue.indexOf('=') + if (eqIndex > 0) result.setOption.push([keyValue.slice(0, eqIndex), keyValue.slice(eqIndex + 1)]) + } else { + const nextArg = args[i + 1] // Next arg is the value + if (nextArg != null) { + const eqIndex = nextArg.indexOf('=') + if (eqIndex > 0) { + result.setOption.push([nextArg.slice(0, eqIndex), nextArg.slice(eqIndex + 1)]) + i++ // Skip next arg + } + } + } + break + default: result.unknown.push(arg) + } + continue + } + + if (arg.startsWith('-') && arg.length > 1) { // Short options + const flags = arg.slice(1) + for (const flag of flags) { + switch (flag) { + case 'h': result.helpFlag = true; break + case 'v': result.versionFlag = true; break + case 'n': result.dryRun = true; break + case 'j': result.jsonFlag = true; break + default: result.unknown.push(`-${flag}`) + } + } + continue + } + + if (!firstPositionalProcessed) { // First positional argument: check if it's a subcommand + firstPositionalProcessed = true + if (VALID_SUBCOMMANDS.has(arg)) result.subcommand = arg as Subcommand + else { + result.unknownCommand = arg // Unknown first positional is captured as unknownCommand + } + continue + } + + result.positional.push(arg) // Remaining positional arguments + } + + return result +} + +/** + * Resolve command from parsed CLI arguments + */ +export function resolveCommand(args: ParsedCliArgs): Command { + const {helpFlag, versionFlag, subcommand, dryRun, unknownCommand, setOption, positional, showFlag} = args + + if (versionFlag) return new VersionCommand() // Version flag takes highest priority + + if (helpFlag) return new HelpCommand() // Help flag takes priority + + if (unknownCommand != null) return new UnknownCommand(unknownCommand) // Unknown command handling + + if (subcommand === 'version') return new VersionCommand() // Version subcommand + + if (subcommand === 'help') return new HelpCommand() // Help subcommand + + if (subcommand === 'outdated') return new OutdatedCommand() // Outdated subcommand + + if (subcommand === 'init') return new InitCommand() // Init subcommand + + if (subcommand === 'dry-run') return new DryRunOutputCommand() // Dry-run subcommand + + if (subcommand === 'clean') { // Clean subcommand with optional dry-run flag + if (dryRun) return new DryRunCleanCommand() + return new CleanCommand() + } + + if (subcommand === 'plugins') return new PluginsCommand() // Plugins subcommand + + if (subcommand === 'config' && showFlag) return new ConfigShowCommand() // Config --show subcommand + + if (subcommand !== 'config' || setOption.length > 0) return new ExecuteCommand() // Config subcommand + + const parsedPositional: [key: string, value: string][] = [] + for (const arg of positional) { + const eqIndex = arg.indexOf('=') + if (eqIndex > 0) parsedPositional.push([arg.slice(0, eqIndex), arg.slice(eqIndex + 1)]) + } + return new ConfigCommand([...setOption, ...parsedPositional]) +} diff --git a/cli/src/pipeline/ContextMerger.ts b/cli/src/pipeline/ContextMerger.ts new file mode 100644 index 00000000..ea2b7a19 --- /dev/null +++ b/cli/src/pipeline/ContextMerger.ts @@ -0,0 +1,270 @@ +/** + * Context Merger Module + * Handles merging of partial CollectedInputContext objects + */ + +import type {CollectedInputContext, Workspace} from '@truenine/plugin-shared' + +/** + * Merge strategy types for context fields + */ +type MergeStrategy = 'concat' | 'override' | 'mergeProjects' + +/** + * Field merge configuration + */ +interface FieldConfig { + readonly strategy: MergeStrategy + readonly getter: (ctx: Partial) => T | undefined +} + +/** + * Merge configuration for all CollectedInputContext fields + */ +const FIELD_CONFIGS: Record> = { + workspace: { + strategy: 'mergeProjects', + getter: ctx => ctx.workspace + }, + vscodeConfigFiles: { + strategy: 'concat', + getter: ctx => ctx.vscodeConfigFiles + }, + jetbrainsConfigFiles: { + strategy: 'concat', + getter: ctx => ctx.jetbrainsConfigFiles + }, + editorConfigFiles: { + strategy: 'concat', + getter: ctx => ctx.editorConfigFiles + }, + fastCommands: { + strategy: 'concat', + getter: ctx => ctx.fastCommands + }, + subAgents: { + strategy: 'concat', + getter: ctx => ctx.subAgents + }, + skills: { + strategy: 'concat', + getter: ctx => ctx.skills + }, + rules: { + strategy: 'concat', + getter: ctx => ctx.rules + }, + aiAgentIgnoreConfigFiles: { + strategy: 'concat', + getter: ctx => ctx.aiAgentIgnoreConfigFiles + }, + readmePrompts: { + strategy: 'concat', + getter: ctx => ctx.readmePrompts + }, + globalMemory: { // Override fields (last one wins) + strategy: 'override', + getter: ctx => ctx.globalMemory + }, + shadowSourceProjectDir: { + strategy: 'override', + getter: ctx => ctx.shadowSourceProjectDir + }, + globalGitIgnore: { + strategy: 'override', + getter: ctx => ctx.globalGitIgnore + }, + shadowGitExclude: { + strategy: 'override', + getter: ctx => ctx.shadowGitExclude + } +} as const + +/** + * Merge two arrays by concatenating them + */ +function mergeArrays(base: readonly T[] | undefined, addition: readonly T[] | undefined): readonly T[] { + if (addition == null) return base ?? [] + if (base == null) return addition + return [...base, ...addition] +} + +/** + * Merge workspace projects. Later projects with the same name replace earlier ones. + */ +function mergeWorkspaceProjects(base: Workspace, addition: Workspace): Workspace { + const projectMap = new Map() + for (const project of base.projects) projectMap.set(project.name, project) + for (const project of addition.projects) projectMap.set(project.name, project) + return { + directory: addition.directory ?? base.directory, + projects: [...projectMap.values()] + } +} + +/** + * Merge workspace fields + */ +function mergeWorkspace(base: Workspace | undefined, addition: Workspace | undefined): Workspace | undefined { + if (addition == null) return base + if (base == null) return addition + return mergeWorkspaceProjects(base, addition) +} + +/** + * Merge a single field based on its strategy + */ +function mergeField( + base: T | undefined, + addition: T | undefined, + strategy: MergeStrategy +): T | undefined { + switch (strategy) { + case 'concat': return mergeArrays(base as unknown[], addition as unknown[]) as unknown as T + case 'override': return addition ?? base + case 'mergeProjects': return mergeWorkspace(base as unknown as Workspace, addition as unknown as Workspace) as unknown as T + default: return addition ?? base + } +} + +/** + * Build merge result object from merged fields + */ +function buildMergeResult( + mergedFields: Map +): Partial { + const result: Record = {} + + for (const [key, value] of mergedFields) { + if (value != null) result[key] = value + } + + return result as Partial +} + +/** + * Merge two partial CollectedInputContext objects + * Uses configuration-driven approach to reduce code duplication + */ +export function mergeContexts( + base: Partial, + addition: Partial +): Partial { + const mergedFields = new Map() + + for (const [fieldName, config] of Object.entries(FIELD_CONFIGS)) { // Process each configured field + const baseValue = config.getter(base) + const additionValue = config.getter(addition) + const mergedValue = mergeField(baseValue, additionValue, config.strategy) + mergedFields.set(fieldName, mergedValue) + } + + return buildMergeResult(mergedFields) +} + +/** + * Legacy merge function for backwards compatibility + * Uses the optimized configuration-driven approach + */ +export function mergeContextsLegacy( + base: Partial, + addition: Partial +): Partial { + return mergeContexts(base, addition) +} + +/** + * Build dependency context from plugin outputs + */ +export function buildDependencyContext( + plugin: {dependsOn?: readonly string[]}, + outputsByPlugin: Map>, + mergeFn: (base: Partial, addition: Partial) => Partial +): Partial { + const deps = plugin.dependsOn ?? [] + if (deps.length === 0) return {} + + const allDeps = collectTransitiveDependencies(plugin, outputsByPlugin) + + let merged: Partial = {} + for (const depName of allDeps) { + const depOutput = outputsByPlugin.get(depName) + if (depOutput != null) merged = mergeFn(merged, depOutput) + } + + return merged +} + +/** + * Collect transitive dependencies for a plugin + */ +function collectTransitiveDependencies( + plugin: {dependsOn?: readonly string[]}, + outputsByPlugin: Map> +): string[] { + const visited = new Set() + const result: string[] = [] + + const visit = (deps: readonly string[]): void => { + for (const dep of deps) { + if (visited.has(dep)) continue + visited.add(dep) + + const depOutput = outputsByPlugin.get(dep) + if (depOutput != null) result.push(dep) + } + } + + visit(plugin.dependsOn ?? []) + return result +} + +/** + * Collect transitive dependencies for a plugin with full dependency resolution + */ +export function collectTransitiveDependenciesFull( + plugin: {dependsOn?: readonly string[]}, + _outputsByPlugin: Map>, + pluginRegistry: Map +): string[] { + const visited = new Set() + const result: string[] = [] + + const visit = (deps: readonly string[]): void => { + for (const dep of deps) { + if (visited.has(dep)) continue + visited.add(dep) + + result.push(dep) + + const depPlugin = pluginRegistry.get(dep) // Recursively visit dependencies of this dependency + if (depPlugin != null) visit(depPlugin.dependsOn ?? []) + } + } + + visit(plugin.dependsOn ?? []) + return result +} + +/** + * Build dependency context with full transitive dependency resolution + */ +export function buildDependencyContextFull( + plugin: {name: string, dependsOn?: readonly string[]}, + outputsByPlugin: Map>, + pluginRegistry: Map, + mergeFn: (base: Partial, addition: Partial) => Partial +): Partial { + const deps = plugin.dependsOn ?? [] + if (deps.length === 0) return {} + + const allDeps = collectTransitiveDependenciesFull(plugin, outputsByPlugin, pluginRegistry) + + let merged: Partial = {} + for (const depName of allDeps) { + const depOutput = outputsByPlugin.get(depName) + if (depOutput != null) merged = mergeFn(merged, depOutput) + } + + return merged +} diff --git a/cli/src/pipeline/PluginDependencyResolver.ts b/cli/src/pipeline/PluginDependencyResolver.ts new file mode 100644 index 00000000..43af20da --- /dev/null +++ b/cli/src/pipeline/PluginDependencyResolver.ts @@ -0,0 +1,148 @@ +/** + * Plugin Dependency Resolver Module + * Handles dependency graph building, validation, and topological sorting + */ + +import type {Plugin, PluginKind} from '@truenine/plugin-shared' +import {CircularDependencyError, MissingDependencyError} from '@truenine/plugin-shared' + +/** + * Build dependency graph from plugins + */ +export function buildDependencyGraph( + plugins: readonly Plugin[] +): Map { + const graph = new Map() + for (const plugin of plugins) { + const deps = plugin.dependsOn ?? [] + graph.set(plugin.name, [...deps]) + } + return graph +} + +/** + * Validate that all plugin dependencies exist + */ +export function validateDependencies( + plugins: readonly Plugin[] +): void { + const pluginNames = new Set(plugins.map(p => p.name)) + for (const plugin of plugins) { + const deps = plugin.dependsOn ?? [] + for (const dep of deps) { + if (!pluginNames.has(dep)) throw new MissingDependencyError(plugin.name, dep) + } + } +} + +/** + * Find cycle path in dependency graph for error reporting + */ +function findCyclePath( + plugins: readonly Plugin[], + inDegree: Map +): string[] { + const cycleNodes = new Set() // Find nodes that are part of a cycle (in-degree > 0) + for (const [name, degree] of inDegree) { + if (degree > 0) cycleNodes.add(name) + } + + const deps = new Map() // Build dependency map for cycle nodes + for (const plugin of plugins) { + if (cycleNodes.has(plugin.name)) { + const pluginDeps = (plugin.dependsOn ?? []).filter(d => cycleNodes.has(d)) + deps.set(plugin.name, pluginDeps) + } + } + + const visited = new Set() // DFS to find cycle path + const path: string[] = [] + + const dfs = (node: string): boolean => { + if (path.includes(node)) { + path.push(node) // Found cycle, add closing node to complete the cycle + return true + } + if (visited.has(node)) return false + + visited.add(node) + path.push(node) + + for (const dep of deps.get(node) ?? []) { + if (dfs(dep)) return true + } + + path.pop() + return false + } + + for (const node of cycleNodes) { // Start DFS from any cycle node + if (dfs(node)) { + const cycleStart = path.indexOf(path.at(-1)!) // Extract just the cycle portion + return path.slice(cycleStart) + } + visited.clear() + path.length = 0 + } + + return [...cycleNodes] // Fallback: return all cycle nodes +} + +/** + * Topologically sort plugins based on dependencies. + * Uses Kahn's algorithm with registration order preservation. + */ +export function topologicalSort( + plugins: readonly Plugin[] +): Plugin[] { + validateDependencies(plugins) // Validate dependencies first + + const pluginMap = new Map>() // Build plugin map for quick lookup + for (const plugin of plugins) pluginMap.set(plugin.name, plugin) + + const inDegree = new Map() // Build in-degree map (count of incoming edges) + for (const plugin of plugins) inDegree.set(plugin.name, 0) + + const dependents = new Map() // Build adjacency list (dependents for each plugin) + for (const plugin of plugins) dependents.set(plugin.name, []) + + for (const plugin of plugins) { // Populate in-degree and dependents + const deps = plugin.dependsOn ?? [] + for (const dep of deps) { + inDegree.set(plugin.name, (inDegree.get(plugin.name) ?? 0) + 1) // Increment in-degree for current plugin + const depList = dependents.get(dep) ?? [] // Add current plugin as dependent of dep + depList.push(plugin.name) + dependents.set(dep, depList) + } + } + + const queue: string[] = [] // Use registration order for initial queue // Initialize queue with plugins that have no dependencies (in-degree = 0) + for (const plugin of plugins) { + if (inDegree.get(plugin.name) === 0) queue.push(plugin.name) + } + + const result: Plugin[] = [] // Process queue + while (queue.length > 0) { + const current = queue.shift()! // Take first element to preserve registration order + const plugin = pluginMap.get(current)! + result.push(plugin) + + const currentDependents = dependents.get(current) ?? [] // Process dependents in registration order + const sortedDependents = currentDependents.sort((a, b) => { // Sort dependents by their original registration order + const indexA = plugins.findIndex(p => p.name === a) + const indexB = plugins.findIndex(p => p.name === b) + return indexA - indexB + }) + + for (const dependent of sortedDependents) { + const newDegree = (inDegree.get(dependent) ?? 0) - 1 + inDegree.set(dependent, newDegree) + if (newDegree === 0) queue.push(dependent) + } + } + + if (result.length === plugins.length) return result // Check for cycle: if not all plugins are in result, there's a cycle + + const cyclePath = findCyclePath(plugins, inDegree) + throw new CircularDependencyError(cyclePath) +} diff --git a/cli/src/pipeline/index.ts b/cli/src/pipeline/index.ts new file mode 100644 index 00000000..684c7292 --- /dev/null +++ b/cli/src/pipeline/index.ts @@ -0,0 +1,23 @@ +export { // Export argument parsing + extractUserArgs, + type LogLevel, + parseArgs, + type ParsedCliArgs, + resolveCommand, + resolveLogLevel, + type Subcommand +} from './CliArgumentParser' + +export { // Export context merging + buildDependencyContext, + buildDependencyContextFull, + collectTransitiveDependenciesFull, + mergeContexts, + mergeContextsLegacy +} from './ContextMerger' + +export { // Export dependency resolution + buildDependencyGraph, + topologicalSort, + validateDependencies +} from './PluginDependencyResolver' diff --git a/cli/src/plugins/plugin-input-agentskills/ResourceProcessor.ts b/cli/src/plugins/plugin-input-agentskills/ResourceProcessor.ts new file mode 100644 index 00000000..18da745e --- /dev/null +++ b/cli/src/plugins/plugin-input-agentskills/ResourceProcessor.ts @@ -0,0 +1,176 @@ +import type {ILogger, SkillChildDoc, SkillResource, SkillResourceEncoding} from '@truenine/plugin-shared' +import type {Dirent} from 'node:fs' +import {Buffer} from 'node:buffer' +import * as nodePath from 'node:path' +import {parseMarkdown, transformMdxReferencesToMd} from '@truenine/md-compiler/markdown' +import {FilePathKind, PromptKind} from '@truenine/plugin-shared' +import {getMimeType, getResourceCategory, isBinaryResourceExtension} from './config/fileTypes' + +/** + * Portable path join that works with Unix-style paths in tests across all platforms + */ +function pathJoin(...segments: string[]): string { + const joined = nodePath.join(...segments) // Normalize to forward slashes for consistent behavior + return joined.replaceAll('\\', '/') +} + +export interface ResourceScanResult { + readonly childDocs: SkillChildDoc[] + readonly resources: SkillResource[] +} + +export interface ResourceProcessorContext { + readonly fs: typeof import('node:fs') + readonly logger: ILogger + readonly skillDir: string +} + +/** + * Resource processor for scanning and processing skill directory contents + * Extracted from SkillInputPlugin to reduce complexity and nesting + */ +export class ResourceProcessor { + private readonly ctx: ResourceProcessorContext + + constructor(ctx: ResourceProcessorContext) { + this.ctx = ctx + } + + processDirectory(entry: Dirent, currentRelativePath: string, filePath: string): ResourceScanResult { + const relativePath = currentRelativePath + ? `${currentRelativePath}/${entry.name}` + : entry.name + + return this.scanSkillDirectory(filePath, relativePath) + } + + processFile(entry: Dirent, currentRelativePath: string, filePath: string): ResourceScanResult { + const relativePath = currentRelativePath + ? `${currentRelativePath}/${entry.name}` + : entry.name + + if (currentRelativePath === '' && entry.name === 'skill.mdx') { // Skip skill.mdx in root directory (handled separately) + return {childDocs: [], resources: []} + } + + if (currentRelativePath === '' && entry.name === 'mcp.json') { // Skip mcp.json in root directory (handled separately) + return {childDocs: [], resources: []} + } + + if (entry.name.endsWith('.mdx')) { + const childDoc = this.processChildDoc(entry.name, relativePath, filePath) + return {childDocs: childDoc ? [childDoc] : [], resources: []} + } + + const resource = this.processResourceFile(entry.name, relativePath, filePath) + return {childDocs: [], resources: resource ? [resource] : []} + } + + private processChildDoc(_fileName: string, relativePath: string, filePath: string): SkillChildDoc | null { + try { + const rawContent = this.ctx.fs.readFileSync(filePath, 'utf8') + const parsed = parseMarkdown(rawContent) + const content = transformMdxReferencesToMd(parsed.contentWithoutFrontMatter) + + return { + 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: this.ctx.skillDir, + getDirectoryName: () => nodePath.dirname(relativePath), + getAbsolutePath: () => filePath + } + } + } + catch (e) { + this.ctx.logger.warn('failed to read child doc', {path: relativePath, error: e}) + return null + } + } + + private processResourceFile(fileName: string, relativePath: string, filePath: string): SkillResource | null { + const ext = nodePath.extname(fileName) + + try { + const {content, encoding, length} = this.readFileContent(filePath, ext) + const mimeType = getMimeType(ext) + + const resource: SkillResource = { + type: PromptKind.SkillResource, + extension: ext, + fileName, + relativePath, + content, + encoding, + category: getResourceCategory(ext), + length, + ...mimeType != null && {mimeType} + } + + return resource + } + catch (e) { + this.ctx.logger.warn('failed to read resource file', {path: relativePath, error: e}) + return null + } + } + + private readFileContent(filePath: string, ext: string): {content: string, encoding: SkillResourceEncoding, length: number} { + if (isBinaryResourceExtension(ext)) { + const buffer = this.ctx.fs.readFileSync(filePath) + return { + content: buffer.toString('base64'), + encoding: 'base64', + length: buffer.length + } + } + + const content = this.ctx.fs.readFileSync(filePath, 'utf8') + return { + content, + encoding: 'text', + length: Buffer.from(content, 'utf8').length + } + } + + scanSkillDirectory(currentDir: string, currentRelativePath: string = ''): ResourceScanResult { + const childDocs: SkillChildDoc[] = [] + const resources: SkillResource[] = [] + + let entries: Dirent[] + try { + entries = this.ctx.fs.readdirSync(currentDir, {withFileTypes: true}) + } + catch (e) { + this.ctx.logger.warn('failed to scan directory', {path: currentDir, error: e}) + return {childDocs, resources} + } + + for (const entry of entries) { + const filePath = pathJoin(currentDir, entry.name) + + if (entry.isDirectory()) { + const subResult = this.processDirectory(entry, currentRelativePath, filePath) + childDocs.push(...subResult.childDocs) + resources.push(...subResult.resources) + continue + } + + if (!entry.isFile()) continue + + const fileResult = this.processFile(entry, currentRelativePath, filePath) + childDocs.push(...fileResult.childDocs) + resources.push(...fileResult.resources) + } + + return {childDocs, resources} + } +} diff --git a/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts b/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts index b8427122..788c440a 100644 --- a/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts +++ b/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts @@ -1,238 +1,169 @@ -import type {CollectedInputContext, ILogger, InputPluginContext, McpServerConfig, SkillChildDoc, SkillMcpConfig, SkillPrompt, SkillResource, SkillResourceCategory, SkillResourceEncoding, SkillYAMLFrontMatter} from '@truenine/plugin-shared' - -import {Buffer} from 'node:buffer' +import type {CollectedInputContext, ILogger, InputPluginContext, McpServerConfig, SkillMcpConfig, SkillPrompt, SkillYAMLFrontMatter} from '@truenine/plugin-shared' +import type {Dirent} from 'node:fs' +import type {ResourceScanResult} from './ResourceProcessor' 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()) -} +import {FilePathKind, PromptKind, validateSkillMetadata} from '@truenine/plugin-shared' +import {ResourceProcessor} from './ResourceProcessor' + +export { + getResourceCategory, + isBinaryResourceExtension +} from './config/fileTypes' // Re-export for backward compatibility + +/** + * Read MCP configuration from mcp.json file + */ +function readMcpConfig( + skillDir: string, + fs: typeof import('node:fs'), + logger: ILogger +): SkillMcpConfig | undefined { + 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 + } -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' + return { + type: PromptKind.SkillMcpConfig, + mcpServers: parsed.mcpServers, + rawContent + } + } + catch (e) { + logger.warn('failed to parse mcp.json', {skillDir, error: e}) + return void 0 + } } -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' +/** + * Process skill file and extract metadata + */ +async function processSkillFile( + skillFilePath: string, + skillDir: string, + entryName: string, + skillAbsoluteDir: string, + ctx: InputPluginContext +): Promise { + const {logger, globalScope, fs} = ctx + + let rawContent: string + try { + rawContent = fs.readFileSync(skillFilePath, 'utf8') + } + catch (e) { + logger.error('failed to read skill file', {file: skillFilePath, error: e}) + return null + } + + let parsed: ReturnType> + try { + parsed = parseMarkdown(rawContent) + } + catch (e) { + logger.error('failed to parse skill markdown', {file: skillFilePath, error: e}) + return null + } + + let compileResult: Awaited> + try { + compileResult = await mdxToMd(rawContent, { + globalScope, + extractMetadata: true, + basePath: skillAbsoluteDir + }) + } + catch (e) { + logger.error('failed to compile skill mdx', {file: skillFilePath, error: e}) + return null + } + + 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) + + logger.debug('skill metadata extracted', { + skill: entryName, + source: compileResult.metadata.source, + hasYaml: parsed.yamlFrontMatter != null, + hasExport: Object.keys(compileResult.metadata.fields).length > 0 + }) + + const processor = new ResourceProcessor({fs, logger, skillDir: skillAbsoluteDir}) + const {childDocs, resources} = processor.scanSkillDirectory(skillAbsoluteDir) + const mcpConfig = readMcpConfig(skillAbsoluteDir, fs, logger) + + const {seriName} = mergedFrontMatter + + return { + type: PromptKind.Skill, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + yamlFrontMatter: mergedFrontMatter.name != null + ? mergedFrontMatter + : {name: entryName, 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: entryName, + basePath: skillDir, + getDirectoryName: () => entryName, + getAbsolutePath: () => path.join(skillDir, entryName) + } } - return mimeTypes[ext.toLowerCase()] +} + +/** + * Check if directory entry is a valid skill + */ +/** + * Check if directory entry is a valid skill + */ +function isValidSkillDirectory( + entry: Dirent, + skillDir: string, + fs: typeof import('node:fs') +): boolean { + if (!entry.isDirectory()) return false + + const skillFilePath = path.join(skillDir, entry.name, 'skill.mdx') + return fs.existsSync(skillFilePath) && fs.statSync(skillFilePath).isFile() } export class SkillInputPlugin extends AbstractInputPlugin { @@ -244,35 +175,8 @@ export class SkillInputPlugin extends AbstractInputPlugin { 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 - } + ): SkillMcpConfig | undefined { + return readMcpConfig(skillDir, fs, logger) } scanSkillDirectory( @@ -280,197 +184,53 @@ export class SkillInputPlugin extends AbstractInputPlugin { 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} + ): ResourceScanResult { + const processor = new ResourceProcessor({fs, logger, skillDir}) + return processor.scanSkillDirectory(skillDir, currentRelativePath) // When called recursively, currentRelativePath is set and we join paths // When called from tests with empty currentRelativePath, we need to use skillDir as currentDir } async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger, globalScope} = ctx + const {userConfigOptions: options, logger} = 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}) + if (!(ctx.fs.existsSync(skillDir) && ctx.fs.statSync(skillDir).isDirectory())) { // Early return if skill directory doesn't exist + return {skills} + } + + let entries: Dirent[] + try { + entries = ctx.fs.readdirSync(skillDir, {withFileTypes: true}) + } + catch (e) { + logger.warn('failed to read skill directory', {skillDir, error: e}) + return {skills} + } + 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}) - } - } + if (!isValidSkillDirectory(entry, skillDir, ctx.fs)) continue + + const entryName = entry.name + const skillFilePath = ctx.path.join(skillDir, entryName, 'skill.mdx') + const skillAbsoluteDir = ctx.path.join(skillDir, entryName) + + try { + const skill = await processSkillFile( + skillFilePath, + skillDir, + entryName, + skillAbsoluteDir, + ctx + ) + if (skill) skills.push(skill) + } + catch (e) { + logger.error('failed to parse skill', {file: skillFilePath, error: e}) } } + return {skills} } } diff --git a/cli/src/plugins/plugin-input-agentskills/config/fileTypes.ts b/cli/src/plugins/plugin-input-agentskills/config/fileTypes.ts new file mode 100644 index 00000000..e1c8752a --- /dev/null +++ b/cli/src/plugins/plugin-input-agentskills/config/fileTypes.ts @@ -0,0 +1,199 @@ +/** + * File type categorization configuration + * Centralizes extension definitions to reduce code duplication + */ + +export interface FileTypeCategories { + readonly image: readonly string[] + readonly code: readonly string[] + readonly data: readonly string[] + readonly document: readonly string[] + readonly config: readonly string[] + readonly script: readonly string[] + readonly binary: readonly string[] +} + +export const FILE_TYPE_CATEGORIES: FileTypeCategories = { + image: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.tiff', '.svg'], + code: [ + '.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' + ], + data: ['.sql', '.json', '.jsonc', '.json5', '.xml', '.xsd', '.xsl', '.xslt', '.yaml', '.yml', '.toml', '.csv', '.tsv', '.graphql', '.gql', '.proto'], + document: ['.txt', '.text', '.rtf', '.log', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt', '.pdf', '.odt', '.ods', '.odp'], + config: ['.ini', '.conf', '.cfg', '.config', '.properties', '.env', '.envrc', '.editorconfig', '.gitignore', '.gitattributes', '.npmrc', '.nvmrc', '.npmignore', '.eslintrc', '.prettierrc', '.stylelintrc', '.babelrc', '.browserslistrc'], + script: ['.sh', '.bash', '.zsh', '.fish', '.ps1', '.psm1', '.psd1', '.bat', '.cmd'], + binary: ['.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'] +} as const + +export const SKILL_RESOURCE_BINARY_EXTENSIONS: readonly string[] = [ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.ico', + '.bmp', + '.tiff', + '.svg', + '.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', + '.pdf', + '.docx', + '.doc', + '.xlsx', + '.xls', + '.pptx', + '.ppt', + '.odt', + '.ods', + '.odp' +] as const + +export type ResourceCategory = 'image' | 'code' | 'data' | 'document' | 'config' | 'script' | 'binary' | 'other' +export type ResourceEncoding = 'text' | 'base64' + +/** + * Get resource category based on file extension + */ +export function getResourceCategory(ext: string): ResourceCategory { + const lowerExt = ext.toLowerCase() + + if (FILE_TYPE_CATEGORIES.image.includes(lowerExt)) return 'image' + if (FILE_TYPE_CATEGORIES.code.includes(lowerExt)) return 'code' + if (FILE_TYPE_CATEGORIES.data.includes(lowerExt)) return 'data' + if (FILE_TYPE_CATEGORIES.document.includes(lowerExt)) return 'document' + if (FILE_TYPE_CATEGORIES.config.includes(lowerExt)) return 'config' + if (FILE_TYPE_CATEGORIES.script.includes(lowerExt)) return 'script' + if (FILE_TYPE_CATEGORIES.binary.includes(lowerExt)) return 'binary' + + return 'other' +} + +/** + * Check if extension is a binary resource type + */ +export function isBinaryResourceExtension(ext: string): boolean { + return SKILL_RESOURCE_BINARY_EXTENSIONS.includes(ext.toLowerCase()) +} + +/** + * Common MIME types for resources + */ +export const MIME_TYPES: 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' +} as const + +/** + * Get MIME type for file extension + */ +export function getMimeType(ext: string): string | undefined { + return MIME_TYPES[ext.toLowerCase()] +} From 2d661ae9b595c489d9fed8205801cda357d8aeb1 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 05:29:50 +0800 Subject: [PATCH 06/30] =?UTF-8?q?refactor(cli):=20=E6=9B=BF=E6=8D=A2=20con?= =?UTF-8?q?sole=20=E8=BE=93=E5=87=BA=E4=B8=BA=20logger=20=E5=B9=B6?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 console.log/error 替换为 logger 进行输出 - 改进错误处理,添加错误类型检查和格式化 - 更新 ConfigCommand 的类型定义和嵌套值处理 - 修改 HelpCommand 测试以适配 logger 变更 --- cli/src/commands/CommandFactory.ts | 29 ++++++++ cli/src/commands/CommandRegistry.ts | 48 ++++++++++++ cli/src/commands/CommandRegistryFactory.ts | 37 ++++++++++ cli/src/commands/ConfigCommand.ts | 28 ++++--- cli/src/commands/HelpCommand.test.ts | 39 +++++----- cli/src/commands/HelpCommand.ts | 5 +- cli/src/commands/UnknownCommand.ts | 10 +-- .../commands/factories/CleanCommandFactory.ts | 20 +++++ .../factories/ConfigCommandFactory.ts | 29 ++++++++ .../factories/DryRunCommandFactory.ts | 18 +++++ .../factories/ExecuteCommandFactory.ts | 18 +++++ .../commands/factories/HelpCommandFactory.ts | 21 ++++++ .../commands/factories/InitCommandFactory.ts | 18 +++++ .../factories/OutdatedCommandFactory.ts | 18 +++++ .../factories/PluginsCommandFactory.ts | 18 +++++ .../factories/UnknownCommandFactory.ts | 21 ++++++ .../factories/VersionCommandFactory.ts | 21 ++++++ cli/src/commands/factories/index.ts | 41 ++++++++++ cli/src/commands/index.ts | 16 ++++ cli/src/index.ts | 7 +- cli/src/pipeline/CliArgumentParser.ts | 74 +++++++------------ cli/src/plugin-runtime.ts | 4 +- 22 files changed, 450 insertions(+), 90 deletions(-) create mode 100644 cli/src/commands/CommandFactory.ts create mode 100644 cli/src/commands/CommandRegistry.ts create mode 100644 cli/src/commands/CommandRegistryFactory.ts create mode 100644 cli/src/commands/factories/CleanCommandFactory.ts create mode 100644 cli/src/commands/factories/ConfigCommandFactory.ts create mode 100644 cli/src/commands/factories/DryRunCommandFactory.ts create mode 100644 cli/src/commands/factories/ExecuteCommandFactory.ts create mode 100644 cli/src/commands/factories/HelpCommandFactory.ts create mode 100644 cli/src/commands/factories/InitCommandFactory.ts create mode 100644 cli/src/commands/factories/OutdatedCommandFactory.ts create mode 100644 cli/src/commands/factories/PluginsCommandFactory.ts create mode 100644 cli/src/commands/factories/UnknownCommandFactory.ts create mode 100644 cli/src/commands/factories/VersionCommandFactory.ts create mode 100644 cli/src/commands/factories/index.ts diff --git a/cli/src/commands/CommandFactory.ts b/cli/src/commands/CommandFactory.ts new file mode 100644 index 00000000..226468f0 --- /dev/null +++ b/cli/src/commands/CommandFactory.ts @@ -0,0 +1,29 @@ +import type {Command} from './Command' +import type {ParsedCliArgs} from '@/pipeline' + +/** + * Command factory interface + * Each factory knows how to create a specific command based on CLI args + */ +export interface CommandFactory { + canHandle: (args: ParsedCliArgs) => boolean + + createCommand: (args: ParsedCliArgs) => Command +} + +/** + * Priority levels for command factory resolution + * Lower number = higher priority + */ +export enum FactoryPriority { + Flags = 0, // --version, --help flags (highest priority) + Unknown = 1, // Unknown command handling + Subcommand = 2 // Named subcommands +} + +/** + * Extended factory interface with priority + */ +export interface PrioritizedCommandFactory extends CommandFactory { + readonly priority: FactoryPriority +} diff --git a/cli/src/commands/CommandRegistry.ts b/cli/src/commands/CommandRegistry.ts new file mode 100644 index 00000000..743607ee --- /dev/null +++ b/cli/src/commands/CommandRegistry.ts @@ -0,0 +1,48 @@ +import type {Command} from './Command' +import type {CommandFactory, PrioritizedCommandFactory} from './CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {FactoryPriority} from './CommandFactory' + +/** + * Command registry that manages command factories + * Uses priority-based resolution for factory selection + */ +export class CommandRegistry { + private readonly factories: PrioritizedCommandFactory[] = [] + + register(factory: PrioritizedCommandFactory): void { + this.factories.push(factory) + this.factories.sort((a, b) => a.priority - b.priority) // Sort by priority (lower number = higher priority) + } + + registerWithPriority(factory: CommandFactory, priority: FactoryPriority): void { + const prioritized: PrioritizedCommandFactory = { // Create a wrapper that delegates to the original factory while adding priority + priority, + canHandle: (args: ParsedCliArgs) => factory.canHandle(args), + createCommand: (args: ParsedCliArgs) => factory.createCommand(args) + } + this.factories.push(prioritized) + this.factories.sort((a, b) => a.priority - b.priority) + } + + registerAll(factories: PrioritizedCommandFactory[]): void { + for (const factory of factories) this.factories.push(factory) + this.factories.sort((a, b) => a.priority - b.priority) // Sort by priority after all registrations + } + + resolve(args: ParsedCliArgs): Command { + for (const factory of this.factories) { // First pass: check prioritized factories (flags, unknown commands) + if (factory.priority <= FactoryPriority.Unknown && factory.canHandle(args)) return factory.createCommand(args) + } + + for (const factory of this.factories) { // Second pass: check subcommand factories + if (factory.priority === FactoryPriority.Subcommand && factory.canHandle(args)) return factory.createCommand(args) + } + + for (const factory of this.factories) { // Third pass: use catch-all factory (ExecuteCommandFactory) + if (factory.canHandle(args)) return factory.createCommand(args) + } + + throw new Error('No command factory found for the given arguments') // This should never happen if ExecuteCommandFactory is registered + } +} diff --git a/cli/src/commands/CommandRegistryFactory.ts b/cli/src/commands/CommandRegistryFactory.ts new file mode 100644 index 00000000..a8704a9e --- /dev/null +++ b/cli/src/commands/CommandRegistryFactory.ts @@ -0,0 +1,37 @@ +import {FactoryPriority} from './CommandFactory' +import {CommandRegistry} from './CommandRegistry' +import {CleanCommandFactory} from './factories/CleanCommandFactory' +import {ConfigCommandFactory} from './factories/ConfigCommandFactory' +import {DryRunCommandFactory} from './factories/DryRunCommandFactory' +import {ExecuteCommandFactory} from './factories/ExecuteCommandFactory' +import {HelpCommandFactory} from './factories/HelpCommandFactory' +import {InitCommandFactory} from './factories/InitCommandFactory' +import {OutdatedCommandFactory} from './factories/OutdatedCommandFactory' +import {PluginsCommandFactory} from './factories/PluginsCommandFactory' +import {UnknownCommandFactory} from './factories/UnknownCommandFactory' +import {VersionCommandFactory} from './factories/VersionCommandFactory' + +/** + * Create a default command registry with all standard factories pre-registered + * + * This is in a separate file to avoid circular dependencies between + * CommandRegistry -> Factories -> Commands -> index + */ +export function createDefaultCommandRegistry(): CommandRegistry { + const registry = new CommandRegistry() + + registry.register(new VersionCommandFactory()) // High priority: flag-based commands + registry.register(new HelpCommandFactory()) + registry.register(new UnknownCommandFactory()) + + registry.registerWithPriority(new OutdatedCommandFactory(), FactoryPriority.Subcommand) // Normal priority: subcommand-based commands + registry.registerWithPriority(new InitCommandFactory(), FactoryPriority.Subcommand) + registry.registerWithPriority(new DryRunCommandFactory(), FactoryPriority.Subcommand) + registry.registerWithPriority(new CleanCommandFactory(), FactoryPriority.Subcommand) + registry.registerWithPriority(new PluginsCommandFactory(), FactoryPriority.Subcommand) + registry.registerWithPriority(new ConfigCommandFactory(), FactoryPriority.Subcommand) + + registry.registerWithPriority(new ExecuteCommandFactory(), FactoryPriority.Subcommand) // Lowest priority: default/catch-all command + + return registry +} diff --git a/cli/src/commands/ConfigCommand.ts b/cli/src/commands/ConfigCommand.ts index 0a237fad..5ddf64d3 100644 --- a/cli/src/commands/ConfigCommand.ts +++ b/cli/src/commands/ConfigCommand.ts @@ -55,12 +55,12 @@ function getGlobalConfigPath(): string { /** * Read global config file */ -function readGlobalConfig(): Record { +function readGlobalConfig(): ConfigObject { const configPath = getGlobalConfigPath() if (!fs.existsSync(configPath)) return {} try { const content = fs.readFileSync(configPath, 'utf8') - return JSON.parse(content) as Record + return JSON.parse(content) as ConfigObject } catch { return {} @@ -70,7 +70,7 @@ function readGlobalConfig(): Record { /** * Write global config file */ -function writeGlobalConfig(config: Record): void { +function writeGlobalConfig(config: ConfigObject): void { const configPath = getGlobalConfigPath() const configDir = path.dirname(configPath) @@ -79,16 +79,22 @@ function writeGlobalConfig(config: Record): void { fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8') // Write with pretty formatting } +type ConfigValue = string | ConfigObject +interface ConfigObject { + [key: string]: ConfigValue | undefined +} + /** * Set a nested value in an object using dot-notation key */ -function setNestedValue(obj: Record, key: string, value: string): void { +function setNestedValue(obj: ConfigObject, key: string, value: string): void { const parts = key.split('.') - let current = obj + let current: ConfigObject = obj for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]! - if (typeof current[part] !== 'object' || current[part] === null) current[part] = {} - current = current[part] as Record + const next = current[part] + if (typeof next !== 'object' || next === null || Array.isArray(next)) current[part] = {} + current = current[part] as ConfigObject } current[parts.at(-1)!] = value } @@ -96,12 +102,12 @@ function setNestedValue(obj: Record, key: string, value: string /** * Get a nested value from an object using dot-notation key */ -function getNestedValue(obj: Record, key: string): unknown { +function getNestedValue(obj: ConfigObject, key: string): ConfigValue | undefined { const parts = key.split('.') - let current: unknown = obj + let current: ConfigValue | undefined = obj for (const part of parts) { - if (typeof current !== 'object' || current === null) return void 0 - current = (current as Record)[part] + if (typeof current !== 'object' || current === null || Array.isArray(current)) return void 0 + current = current[part] } return current } diff --git a/cli/src/commands/HelpCommand.test.ts b/cli/src/commands/HelpCommand.test.ts index ec4fc18c..0dff71f5 100644 --- a/cli/src/commands/HelpCommand.test.ts +++ b/cli/src/commands/HelpCommand.test.ts @@ -1,78 +1,77 @@ -import {createLogger} from '@truenine/plugin-shared' +import type {ILogger} from '@truenine/plugin-shared' import {describe, expect, it, vi} from 'vitest' import {HelpCommand} from './HelpCommand' -const mockLogger = createLogger('test', 'error') +function createMockLogger(): ILogger { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + } +} describe('helpCommand', () => { describe('help text content', () => { it('should list all subcommands', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }) + const mockLogger = createMockLogger() const command = new HelpCommand() await command.execute({logger: mockLogger} as any) - const helpText = consoleSpy.mock.calls[0][0] as string + const helpText = (mockLogger.info as ReturnType).mock.calls[0]?.[0] as string expect(helpText).toContain('help') // Verify all subcommands are listed (Requirements 8.1) expect(helpText).toContain('init') expect(helpText).toContain('dry-run') expect(helpText).toContain('clean') - - consoleSpy.mockRestore() }) it('should list all log level options', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }) + const mockLogger = createMockLogger() const command = new HelpCommand() await command.execute({logger: mockLogger} as any) - const helpText = consoleSpy.mock.calls[0][0] as string + const helpText = (mockLogger.info as ReturnType).mock.calls[0]?.[0] as string expect(helpText).toContain('--trace') // Verify all log level options are listed (Requirements 8.2) expect(helpText).toContain('--debug') expect(helpText).toContain('--info') expect(helpText).toContain('--warn') expect(helpText).toContain('--error') - - consoleSpy.mockRestore() }) it('should show clean dry-run options', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }) + const mockLogger = createMockLogger() const command = new HelpCommand() await command.execute({logger: mockLogger} as any) - const helpText = consoleSpy.mock.calls[0][0] as string + const helpText = (mockLogger.info as ReturnType).mock.calls[0]?.[0] as string expect(helpText).toContain('-n') // Verify clean options are shown (Requirements 8.3) expect(helpText).toContain('--dry-run') expect(helpText).toContain('clean --dry-run') - - consoleSpy.mockRestore() }) it('should include usage examples', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }) + const mockLogger = createMockLogger() const command = new HelpCommand() await command.execute({logger: mockLogger} as any) - const helpText = consoleSpy.mock.calls[0][0] as string + const helpText = (mockLogger.info as ReturnType).mock.calls[0]?.[0] as string expect(helpText).toContain('USAGE:') // Verify examples are included (Requirements 8.4) expect(helpText).toContain('tnmsc help') expect(helpText).toContain('tnmsc init') expect(helpText).toContain('tnmsc dry-run') expect(helpText).toContain('tnmsc clean') - - consoleSpy.mockRestore() }) it('should return success result', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }) + const mockLogger = createMockLogger() const command = new HelpCommand() const result = await command.execute({logger: mockLogger} as any) @@ -80,8 +79,6 @@ describe('helpCommand', () => { expect(result.success).toBe(true) expect(result.filesAffected).toBe(0) expect(result.dirsAffected).toBe(0) - - consoleSpy.mockRestore() }) }) }) diff --git a/cli/src/commands/HelpCommand.ts b/cli/src/commands/HelpCommand.ts index 870d0d19..813bd280 100644 --- a/cli/src/commands/HelpCommand.ts +++ b/cli/src/commands/HelpCommand.ts @@ -72,9 +72,8 @@ CONFIGURATION: export class HelpCommand implements Command { readonly name = 'help' - async execute(_ctx: CommandContext): Promise { - // eslint-disable-next-line no-console - console.log(HELP_TEXT) + async execute(ctx: CommandContext): Promise { + ctx.logger.info(HELP_TEXT) return { success: true, diff --git a/cli/src/commands/UnknownCommand.ts b/cli/src/commands/UnknownCommand.ts index b11394d0..b39c267a 100644 --- a/cli/src/commands/UnknownCommand.ts +++ b/cli/src/commands/UnknownCommand.ts @@ -8,15 +8,15 @@ export class UnknownCommand implements Command { constructor(private readonly unknownCmd: string) { } - async execute(_ctx: CommandContext): Promise { - console.error(`Unknown command: ${this.unknownCmd}`) - - console.error('Run "tnmsc help" for available commands.') + async execute(ctx: CommandContext): Promise { + ctx.logger.error('unknown command', {command: this.unknownCmd}) + ctx.logger.info('run "tnmsc help" for available commands') return { success: false, filesAffected: 0, - dirsAffected: 0 + dirsAffected: 0, + message: `Unknown command: ${this.unknownCmd}` } } } diff --git a/cli/src/commands/factories/CleanCommandFactory.ts b/cli/src/commands/factories/CleanCommandFactory.ts new file mode 100644 index 00000000..8232337d --- /dev/null +++ b/cli/src/commands/factories/CleanCommandFactory.ts @@ -0,0 +1,20 @@ +import type {Command} from '../Command' +import type {CommandFactory} from '../CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {CleanCommand} from '../CleanCommand' +import {DryRunCleanCommand} from '../DryRunCleanCommand' + +/** + * Factory for creating CleanCommand or DryRunCleanCommand + * Handles 'clean' subcommand with optional --dry-run flag + */ +export class CleanCommandFactory implements CommandFactory { + canHandle(args: ParsedCliArgs): boolean { + return args.subcommand === 'clean' + } + + createCommand(args: ParsedCliArgs): Command { + if (args.dryRun) return new DryRunCleanCommand() + return new CleanCommand() + } +} diff --git a/cli/src/commands/factories/ConfigCommandFactory.ts b/cli/src/commands/factories/ConfigCommandFactory.ts new file mode 100644 index 00000000..95edea98 --- /dev/null +++ b/cli/src/commands/factories/ConfigCommandFactory.ts @@ -0,0 +1,29 @@ +import type {Command} from '../Command' +import type {CommandFactory} from '../CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {ConfigCommand} from '../ConfigCommand' +import {ConfigShowCommand} from '../ConfigShowCommand' + +/** + * Factory for creating ConfigCommand or ConfigShowCommand + * Handles 'config' subcommand with --show flag or key=value arguments + */ +export class ConfigCommandFactory implements CommandFactory { + canHandle(args: ParsedCliArgs): boolean { + return args.subcommand === 'config' + } + + createCommand(args: ParsedCliArgs): Command { + if (args.showFlag) { // Config --show subcommand + return new ConfigShowCommand() + } + + const parsedPositional: [key: string, value: string][] = [] // Parse positional arguments as key=value pairs + for (const arg of args.positional) { + const eqIndex = arg.indexOf('=') + if (eqIndex > 0) parsedPositional.push([arg.slice(0, eqIndex), arg.slice(eqIndex + 1)]) + } + + return new ConfigCommand([...args.setOption, ...parsedPositional]) + } +} diff --git a/cli/src/commands/factories/DryRunCommandFactory.ts b/cli/src/commands/factories/DryRunCommandFactory.ts new file mode 100644 index 00000000..b827e043 --- /dev/null +++ b/cli/src/commands/factories/DryRunCommandFactory.ts @@ -0,0 +1,18 @@ +import type {Command} from '../Command' +import type {CommandFactory} from '../CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {DryRunOutputCommand} from '../DryRunOutputCommand' + +/** + * Factory for creating DryRunOutputCommand + * Handles 'dry-run' subcommand + */ +export class DryRunCommandFactory implements CommandFactory { + canHandle(args: ParsedCliArgs): boolean { + return args.subcommand === 'dry-run' + } + + createCommand(_args: ParsedCliArgs): Command { + return new DryRunOutputCommand() + } +} diff --git a/cli/src/commands/factories/ExecuteCommandFactory.ts b/cli/src/commands/factories/ExecuteCommandFactory.ts new file mode 100644 index 00000000..248688bf --- /dev/null +++ b/cli/src/commands/factories/ExecuteCommandFactory.ts @@ -0,0 +1,18 @@ +import type {Command} from '../Command' +import type {CommandFactory} from '../CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {ExecuteCommand} from '../ExecuteCommand' + +/** + * Factory for creating ExecuteCommand (default command) + * Handles default execution when no specific subcommand matches + */ +export class ExecuteCommandFactory implements CommandFactory { + canHandle(_args: ParsedCliArgs): boolean { // This is a catch-all factory with lowest priority + return true + } + + createCommand(_args: ParsedCliArgs): Command { + return new ExecuteCommand() + } +} diff --git a/cli/src/commands/factories/HelpCommandFactory.ts b/cli/src/commands/factories/HelpCommandFactory.ts new file mode 100644 index 00000000..ab8599e0 --- /dev/null +++ b/cli/src/commands/factories/HelpCommandFactory.ts @@ -0,0 +1,21 @@ +import type {Command} from '../Command' +import type {PrioritizedCommandFactory} from '../CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {FactoryPriority} from '../CommandFactory' +import {HelpCommand} from '../HelpCommand' + +/** + * Factory for creating HelpCommand + * Handles --help flag and 'help' subcommand + */ +export class HelpCommandFactory implements PrioritizedCommandFactory { + readonly priority = FactoryPriority.Flags + + canHandle(args: ParsedCliArgs): boolean { + return args.helpFlag || args.subcommand === 'help' + } + + createCommand(_args: ParsedCliArgs): Command { + return new HelpCommand() + } +} diff --git a/cli/src/commands/factories/InitCommandFactory.ts b/cli/src/commands/factories/InitCommandFactory.ts new file mode 100644 index 00000000..971b757d --- /dev/null +++ b/cli/src/commands/factories/InitCommandFactory.ts @@ -0,0 +1,18 @@ +import type {Command} from '../Command' +import type {CommandFactory} from '../CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {InitCommand} from '../InitCommand' + +/** + * Factory for creating InitCommand + * Handles 'init' subcommand + */ +export class InitCommandFactory implements CommandFactory { + canHandle(args: ParsedCliArgs): boolean { + return args.subcommand === 'init' + } + + createCommand(_args: ParsedCliArgs): Command { + return new InitCommand() + } +} diff --git a/cli/src/commands/factories/OutdatedCommandFactory.ts b/cli/src/commands/factories/OutdatedCommandFactory.ts new file mode 100644 index 00000000..ed709e71 --- /dev/null +++ b/cli/src/commands/factories/OutdatedCommandFactory.ts @@ -0,0 +1,18 @@ +import type {Command} from '../Command' +import type {CommandFactory} from '../CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {OutdatedCommand} from '../OutdatedCommand' + +/** + * Factory for creating OutdatedCommand + * Handles 'outdated' subcommand + */ +export class OutdatedCommandFactory implements CommandFactory { + canHandle(args: ParsedCliArgs): boolean { + return args.subcommand === 'outdated' + } + + createCommand(_args: ParsedCliArgs): Command { + return new OutdatedCommand() + } +} diff --git a/cli/src/commands/factories/PluginsCommandFactory.ts b/cli/src/commands/factories/PluginsCommandFactory.ts new file mode 100644 index 00000000..e92bab56 --- /dev/null +++ b/cli/src/commands/factories/PluginsCommandFactory.ts @@ -0,0 +1,18 @@ +import type {Command} from '../Command' +import type {CommandFactory} from '../CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {PluginsCommand} from '../PluginsCommand' + +/** + * Factory for creating PluginsCommand + * Handles 'plugins' subcommand + */ +export class PluginsCommandFactory implements CommandFactory { + canHandle(args: ParsedCliArgs): boolean { + return args.subcommand === 'plugins' + } + + createCommand(_args: ParsedCliArgs): Command { + return new PluginsCommand() + } +} diff --git a/cli/src/commands/factories/UnknownCommandFactory.ts b/cli/src/commands/factories/UnknownCommandFactory.ts new file mode 100644 index 00000000..c57c8481 --- /dev/null +++ b/cli/src/commands/factories/UnknownCommandFactory.ts @@ -0,0 +1,21 @@ +import type {Command} from '../Command' +import type {PrioritizedCommandFactory} from '../CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {FactoryPriority} from '../CommandFactory' +import {UnknownCommand} from '../UnknownCommand' + +/** + * Factory for creating UnknownCommand + * Handles unknown/invalid subcommands + */ +export class UnknownCommandFactory implements PrioritizedCommandFactory { + readonly priority = FactoryPriority.Unknown + + canHandle(args: ParsedCliArgs): boolean { + return args.unknownCommand != null + } + + createCommand(args: ParsedCliArgs): Command { + return new UnknownCommand(args.unknownCommand!) + } +} diff --git a/cli/src/commands/factories/VersionCommandFactory.ts b/cli/src/commands/factories/VersionCommandFactory.ts new file mode 100644 index 00000000..dcc38974 --- /dev/null +++ b/cli/src/commands/factories/VersionCommandFactory.ts @@ -0,0 +1,21 @@ +import type {Command} from '../Command' +import type {PrioritizedCommandFactory} from '../CommandFactory' +import type {ParsedCliArgs} from '@/pipeline' +import {FactoryPriority} from '../CommandFactory' +import {VersionCommand} from '../VersionCommand' + +/** + * Factory for creating VersionCommand + * Handles --version flag and 'version' subcommand + */ +export class VersionCommandFactory implements PrioritizedCommandFactory { + readonly priority = FactoryPriority.Flags + + canHandle(args: ParsedCliArgs): boolean { + return args.versionFlag || args.subcommand === 'version' + } + + createCommand(_args: ParsedCliArgs): Command { + return new VersionCommand() + } +} diff --git a/cli/src/commands/factories/index.ts b/cli/src/commands/factories/index.ts new file mode 100644 index 00000000..14e301f4 --- /dev/null +++ b/cli/src/commands/factories/index.ts @@ -0,0 +1,41 @@ +export type { + CommandFactory, + PrioritizedCommandFactory +} from '../CommandFactory' // Command Factory exports +export { + FactoryPriority +} from '../CommandFactory' +export { + CommandRegistry +} from '../CommandRegistry' + +export { + CleanCommandFactory +} from './CleanCommandFactory' +export { + ConfigCommandFactory +} from './ConfigCommandFactory' +export { + DryRunCommandFactory +} from './DryRunCommandFactory' +export { + ExecuteCommandFactory +} from './ExecuteCommandFactory' +export { + HelpCommandFactory +} from './HelpCommandFactory' +export { + InitCommandFactory +} from './InitCommandFactory' +export { + OutdatedCommandFactory +} from './OutdatedCommandFactory' +export { + PluginsCommandFactory +} from './PluginsCommandFactory' +export { + UnknownCommandFactory +} from './UnknownCommandFactory' +export { + VersionCommandFactory +} from './VersionCommandFactory' // Factory implementations diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts index 1d057e76..5a26714f 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -1,16 +1,32 @@ export * from './CleanCommand' export * from './CleanupUtils' export * from './Command' +export type { + CommandFactory, + PrioritizedCommandFactory +} from './CommandFactory' // Command Factory exports +export { + FactoryPriority +} from './CommandFactory' +export { + CommandRegistry +} from './CommandRegistry' +export { + createDefaultCommandRegistry +} from './CommandRegistryFactory' export * from './CommandUtils' export * from './ConfigCommand' export * from './ConfigShowCommand' export * from './DryRunCleanCommand' export * from './DryRunOutputCommand' export * from './ExecuteCommand' +export * from './factories' // Factory implementations export * from './HelpCommand' export * from './InitCommand' + export * from './JsonOutputCommand' export * from './OutdatedCommand' export * from './PluginsCommand' export * from './UnknownCommand' + export * from './VersionCommand' diff --git a/cli/src/index.ts b/cli/src/index.ts index 09aac66b..f973f8ef 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,4 +1,5 @@ import process from 'node:process' +import {createLogger} from '@truenine/plugin-shared' import {PluginPipeline} from '@/PluginPipeline' import userPluginConfigPromise from './plugin.config' @@ -16,4 +17,8 @@ async function main(): Promise { await pipeline.run(userPluginConfig) } -main().catch((e: unknown) => console.error(e)) +main().catch((e: unknown) => { + const logger = createLogger('main', 'error') + logger.error('unhandled error', {error: e instanceof Error ? e.message : String(e)}) + process.exit(1) +}) diff --git a/cli/src/pipeline/CliArgumentParser.ts b/cli/src/pipeline/CliArgumentParser.ts index 907516f8..7476c347 100644 --- a/cli/src/pipeline/CliArgumentParser.ts +++ b/cli/src/pipeline/CliArgumentParser.ts @@ -1,23 +1,12 @@ /** * CLI Argument Parser Module * Handles extraction and parsing of command-line arguments + * + * Refactored to use Command Factory pattern for command creation */ import type {Command} from '@/commands' -import { - CleanCommand, - ConfigCommand, - ConfigShowCommand, - DryRunCleanCommand, - DryRunOutputCommand, - ExecuteCommand, - HelpCommand, - InitCommand, - OutdatedCommand, - PluginsCommand, - UnknownCommand, - VersionCommand -} from '@/commands' +import {createDefaultCommandRegistry} from '@/commands/CommandRegistryFactory' /** * Valid subcommands for the CLI @@ -246,42 +235,31 @@ export function parseArgs(args: readonly string[]): ParsedCliArgs { } /** - * Resolve command from parsed CLI arguments + * Singleton instance of the command registry + * Lazy-loaded to ensure factories are only created when needed */ -export function resolveCommand(args: ParsedCliArgs): Command { - const {helpFlag, versionFlag, subcommand, dryRun, unknownCommand, setOption, positional, showFlag} = args - - if (versionFlag) return new VersionCommand() // Version flag takes highest priority - - if (helpFlag) return new HelpCommand() // Help flag takes priority - - if (unknownCommand != null) return new UnknownCommand(unknownCommand) // Unknown command handling - - if (subcommand === 'version') return new VersionCommand() // Version subcommand - - if (subcommand === 'help') return new HelpCommand() // Help subcommand - - if (subcommand === 'outdated') return new OutdatedCommand() // Outdated subcommand - - if (subcommand === 'init') return new InitCommand() // Init subcommand - - if (subcommand === 'dry-run') return new DryRunOutputCommand() // Dry-run subcommand - - if (subcommand === 'clean') { // Clean subcommand with optional dry-run flag - if (dryRun) return new DryRunCleanCommand() - return new CleanCommand() - } +let commandRegistry: ReturnType | undefined - if (subcommand === 'plugins') return new PluginsCommand() // Plugins subcommand - - if (subcommand === 'config' && showFlag) return new ConfigShowCommand() // Config --show subcommand +/** + * Get or create the command registry singleton + */ +function getCommandRegistry(): ReturnType { + commandRegistry ??= createDefaultCommandRegistry() + return commandRegistry +} - if (subcommand !== 'config' || setOption.length > 0) return new ExecuteCommand() // Config subcommand +/** + * Reset the command registry singleton (useful for testing) + */ +export function resetCommandRegistry(): void { + commandRegistry = void 0 +} - const parsedPositional: [key: string, value: string][] = [] - for (const arg of positional) { - const eqIndex = arg.indexOf('=') - if (eqIndex > 0) parsedPositional.push([arg.slice(0, eqIndex), arg.slice(eqIndex + 1)]) - } - return new ConfigCommand([...setOption, ...parsedPositional]) +/** + * Resolve command from parsed CLI arguments using factory pattern + * Delegates command creation to registered factories based on priority + */ +export function resolveCommand(args: ParsedCliArgs): Command { + const registry = getCommandRegistry() + return registry.resolve(args) } diff --git a/cli/src/plugin-runtime.ts b/cli/src/plugin-runtime.ts index b8c2e967..924861d4 100644 --- a/cli/src/plugin-runtime.ts +++ b/cli/src/plugin-runtime.ts @@ -108,6 +108,8 @@ async function main(): Promise { } main().catch((e: unknown) => { - console.error(e) + const errorMessage = e instanceof Error ? e.message : String(e) + const logger = createLogger('plugin-runtime', 'error') + logger.error('unhandled error', {error: errorMessage}) process.exit(1) }) From 4b0c1153315cc76dc8f4e98d05677ee7d1b82f7b Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 05:45:23 +0800 Subject: [PATCH 07/30] =?UTF-8?q?feat(plugin-output-shared):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20McpConfigManager=20=E7=94=A8=E4=BA=8E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20MCP=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 McpConfigManager 集中处理 MCP 配置的收集、转换和写入操作 重构 OpencodeCLIOutputPlugin 和 CursorOutputPlugin 使用新管理器 提供 transformMcpConfigForCursor 和 transformMcpConfigForOpencode 转换函数 --- .../plugin-cursor/CursorOutputPlugin.ts | 84 ++----- .../OpencodeCLIOutputPlugin.ts | 89 +++----- .../plugin-output-shared/McpConfigManager.ts | 210 ++++++++++++++++++ cli/src/plugins/plugin-output-shared/index.ts | 12 + 4 files changed, 274 insertions(+), 121 deletions(-) create mode 100644 cli/src/plugins/plugin-output-shared/McpConfigManager.ts diff --git a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts index 8982842f..ed2811a8 100644 --- a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts +++ b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts @@ -20,10 +20,12 @@ import { filterSkillsByProjectConfig, GlobalConfigDirs, IgnoreFiles, + McpConfigManager, OutputFileNames, OutputPrefixes, OutputSubdirectories, - PreservedSkills + PreservedSkills, + transformMcpConfigForCursor } from '@truenine/plugin-output-shared' import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' @@ -316,56 +318,24 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { } 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} + const mcpManager = new McpConfigManager({fs, logger: this.log}) + const servers = mcpManager.collectMcpServers(skills) - let existingConfig: Record = {} - try { if (this.existsSync(mcpConfigPath)) existingConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')) as Record } - catch { existingConfig = {} } + if (servers.size === 0) return null - const existingMcpServers = (existingConfig['mcpServers'] as Record) ?? {} - existingConfig['mcpServers'] = {...existingMcpServers, ...mergedMcpServers} - const content = JSON.stringify(existingConfig, null, 2) + const transformed = mcpManager.transformMcpServers(servers, transformMcpConfigForCursor) - 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} - } + const globalDir = this.getGlobalConfigDir() + const mcpConfigPath = path.join(globalDir, MCP_CONFIG_FILE) - 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} - } - } + const result = mcpManager.writeCursorMcpConfig(mcpConfigPath, transformed, ctx.dryRun === true) - 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 + return { + path: {pathKind: FilePathKind.Relative, path: MCP_CONFIG_FILE, basePath: globalDir, getDirectoryName: () => GLOBAL_CONFIG_DIR, getAbsolutePath: () => mcpConfigPath}, + success: result.success, + ...result.error != null && {error: result.error}, + ...ctx.dryRun && {skipped: true} } - 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 { @@ -406,21 +376,15 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { 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} + + const mcpManager = new McpConfigManager({fs, logger: this.log}) + const result = mcpManager.writeSkillMcpConfig(mcpConfigPath, skill.mcpConfig!.rawContent, ctx.dryRun === true) + + return { + path: relativePath, + success: result.success, + ...result.error != null && {error: result.error}, + ...ctx.dryRun && {skipped: true} } } diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts index 348eea40..d6737ef2 100644 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts @@ -1,8 +1,15 @@ -import type {FastCommandPrompt, McpServerConfig, OutputPluginContext, OutputWriteContext, RulePrompt, SkillPrompt, SubAgentPrompt, WriteResult, WriteResults} from '@truenine/plugin-shared' +import type {FastCommandPrompt, 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 {applySubSeriesGlobPrefix, BaseCLIOutputPlugin, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' +import { + applySubSeriesGlobPrefix, + BaseCLIOutputPlugin, + filterRulesByProjectConfig, + filterSkillsByProjectConfig, + McpConfigManager, + transformMcpConfigForOpencode +} from '@truenine/plugin-output-shared' import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' const GLOBAL_MEMORY_FILE = 'AGENTS.md' @@ -143,16 +150,12 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { ctx: OutputWriteContext, skills: readonly SkillPrompt[] ): Promise { - const mergedMcpServers: Record = {} + const manager = new McpConfigManager({fs, logger: this.log}) - 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 servers = manager.collectMcpServers(skills) + if (servers.size === 0) return null + const transformed = manager.transformMcpServers(servers, transformMcpConfigForOpencode) const globalDir = this.getGlobalConfigDir() const configPath = path.join(globalDir, OPENCODE_CONFIG_FILE) @@ -164,64 +167,28 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { 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 existingConfig = manager.readExistingConfig(configPath) 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} - } - } + const result = manager.writeOpencodeMcpConfig( + configPath, + transformed, + ctx.dryRun === true, + { + $schema: 'https://opencode.ai/config.json', + plugin: plugins + } + ) - 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'] + if (!result.success) { + if (result.error != null) return {path: relativePath, success: false, error: result.error} + return {path: relativePath, success: false} } - result['enabled'] = config.disabled !== true - - return result + if (result.skipped === true) return {path: relativePath, success: true, skipped: true} + return {path: relativePath, success: true} } protected override async writeSubAgent( diff --git a/cli/src/plugins/plugin-output-shared/McpConfigManager.ts b/cli/src/plugins/plugin-output-shared/McpConfigManager.ts new file mode 100644 index 00000000..2087f08b --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/McpConfigManager.ts @@ -0,0 +1,210 @@ +import type {ILogger, McpServerConfig, SkillPrompt} from '@truenine/plugin-shared' +import * as path from 'node:path' + +/** + * MCP configuration format type + */ +export type McpConfigFormat = 'cursor' | 'opencode' + +/** + * MCP config entry for a single server + */ +export interface McpServerEntry { + readonly name: string + readonly config: McpServerConfig +} + +/** + * Transformed MCP server config for different output formats + */ +export interface TransformedMcpConfig { + [serverName: string]: Record +} + +/** + * Result of MCP config write operation + */ +export interface McpWriteResult { + readonly success: boolean + readonly path: string + readonly serverCount: number + readonly error?: Error + readonly skipped?: boolean +} + +/** + * MCP configuration transformer function type + */ +export type McpConfigTransformer = (config: McpServerConfig) => Record + +/** + * MCP Config Manager + * Handles merging and writing MCP configurations from skills to various output formats + */ +export class McpConfigManager { + private readonly fs: typeof import('node:fs') + private readonly logger: ILogger + + constructor(deps: {fs: typeof import('node:fs'), logger: ILogger}) { + this.fs = deps.fs + this.logger = deps.logger + } + + collectMcpServers(skills: readonly SkillPrompt[]): Map { + const merged = new Map() + + for (const skill of skills) { + if (skill.mcpConfig == null) continue + + for (const [name, config] of Object.entries(skill.mcpConfig.mcpServers)) { + merged.set(name, config) + this.logger.debug('mcp server collected', {skill: skill.yamlFrontMatter.name, mcpName: name}) + } + } + + return merged + } + + transformMcpServers( + servers: Map, + transformer: McpConfigTransformer + ): TransformedMcpConfig { + const result: TransformedMcpConfig = {} + + for (const [name, config] of servers) result[name] = transformer(config) + + return result + } + + readExistingConfig(configPath: string): Record { + try { + if (this.fs.existsSync(configPath)) { + const content = this.fs.readFileSync(configPath, 'utf8') + return JSON.parse(content) as Record + } + } + catch { + this.logger.warn('failed to read existing mcp config, starting fresh', {path: configPath}) + } + return {} + } + + writeCursorMcpConfig( + configPath: string, + servers: TransformedMcpConfig, + dryRun: boolean + ): McpWriteResult { + const existingConfig = this.readExistingConfig(configPath) + const existingMcpServers = (existingConfig['mcpServers'] as Record) ?? {} + + existingConfig['mcpServers'] = {...existingMcpServers, ...servers} + const content = JSON.stringify(existingConfig, null, 2) + + return this.writeConfigFile(configPath, content, Object.keys(servers).length, dryRun) + } + + writeOpencodeMcpConfig( + configPath: string, + servers: TransformedMcpConfig, + dryRun: boolean, + additionalConfig?: Record + ): McpWriteResult { + const existingConfig = this.readExistingConfig(configPath) + + const mergedConfig = { // Merge with additional config (like $schema, plugin array) + ...existingConfig, + ...additionalConfig, + mcp: servers + } + + const content = JSON.stringify(mergedConfig, null, 2) + return this.writeConfigFile(configPath, content, Object.keys(servers).length, dryRun) + } + + writeSkillMcpConfig( + configPath: string, + rawContent: string, + dryRun: boolean + ): McpWriteResult { + return this.writeConfigFile(configPath, rawContent, 1, dryRun) + } + + private ensureDirectory(dir: string): void { + if (!this.fs.existsSync(dir)) this.fs.mkdirSync(dir, {recursive: true}) + } + + private writeConfigFile( + configPath: string, + content: string, + serverCount: number, + dryRun: boolean + ): McpWriteResult { + if (dryRun) { + this.logger.trace({action: 'dryRun', type: 'mcpConfig', path: configPath, serverCount}) + return {success: true, path: configPath, serverCount, skipped: true} + } + + try { + this.ensureDirectory(path.dirname(configPath)) + this.fs.writeFileSync(configPath, content) + this.logger.trace({action: 'write', type: 'mcpConfig', path: configPath, serverCount}) + return {success: true, path: configPath, serverCount} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.logger.error({action: 'write', type: 'mcpConfig', path: configPath, error: errMsg}) + return {success: false, path: configPath, serverCount: 0, error: error as Error} + } + } +} + +/** + * Transform MCP config for Cursor format + * Keeps standard MCP structure with command/args/env or url/headers + */ +export function transformMcpConfigForCursor(config: McpServerConfig): 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 configRecord = config as unknown as Record + const url = configRecord['url'] ?? configRecord['serverUrl'] + + if (url == null) return result + + result['url'] = url + const {headers} = configRecord + if (headers != null) result['headers'] = headers + + return result +} + +/** + * Transform MCP config for Opencode format + * Converts to local (command array) or remote (url) format with enabled flag + */ +export function 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 +} diff --git a/cli/src/plugins/plugin-output-shared/index.ts b/cli/src/plugins/plugin-output-shared/index.ts index e77c0b67..91ef23b3 100644 --- a/cli/src/plugins/plugin-output-shared/index.ts +++ b/cli/src/plugins/plugin-output-shared/index.ts @@ -27,6 +27,18 @@ export { PreservedSkills, ToolPresets } from './constants' +export { + McpConfigManager, + transformMcpConfigForCursor, + transformMcpConfigForOpencode +} from './McpConfigManager' +export type { + McpConfigFormat, + McpConfigTransformer, + McpServerEntry, + McpWriteResult, + TransformedMcpConfig +} from './McpConfigManager' export { applySubSeriesGlobPrefix, filterCommandsByProjectConfig, From 8b7a0623ba69d15abea89b1e3eab6ca0eb3708c0 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 05:54:08 +0800 Subject: [PATCH 08/30] =?UTF-8?q?refactor(cli):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=BD=BF=E7=94=A8=E7=9A=84init=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=8F=8A=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除init命令及其相关实现代码,包括工厂类、测试文件和Rust模块。同时更新帮助信息和文档中对该命令的引用 --- cli/src/cli.rs | 11 --- cli/src/commands/CommandRegistryFactory.ts | 2 - cli/src/commands/InitCommand.test.ts | 80 ------------------- cli/src/commands/InitCommand.ts | 50 ------------ .../commands/factories/InitCommandFactory.ts | 18 ----- cli/src/commands/factories/index.ts | 3 - cli/src/commands/help.rs | 1 - cli/src/commands/index.ts | 2 - cli/src/commands/init.rs | 74 ----------------- cli/src/commands/mod.rs | 1 - cli/src/lib.rs | 72 +---------------- cli/src/main.rs | 3 +- 12 files changed, 2 insertions(+), 315 deletions(-) delete mode 100644 cli/src/commands/InitCommand.test.ts delete mode 100644 cli/src/commands/InitCommand.ts delete mode 100644 cli/src/commands/factories/InitCommandFactory.ts delete mode 100644 cli/src/commands/init.rs diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 192d11eb..29e2fdde 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -52,9 +52,6 @@ pub enum CliCommand { /// Check if CLI version is outdated against npm registry Outdated, - /// Initialize directory structure based on configuration - Init, - /// Preview changes without writing files #[command(name = "dry-run")] DryRun, @@ -158,7 +155,6 @@ pub enum ResolvedCommand { Help, Version, Outdated, - Init, Execute, DryRun, Clean, @@ -198,7 +194,6 @@ pub fn resolve_command(cli: &Cli) -> ResolvedCommand { Some(CliCommand::Help) => ResolvedCommand::Help, Some(CliCommand::Version) => ResolvedCommand::Version, Some(CliCommand::Outdated) => ResolvedCommand::Outdated, - Some(CliCommand::Init) => ResolvedCommand::Init, Some(CliCommand::DryRun) => ResolvedCommand::DryRun, Some(CliCommand::Clean(args)) => { if args.dry_run { @@ -256,12 +251,6 @@ mod tests { assert_eq!(resolve_command(&cli), ResolvedCommand::Outdated); } - #[test] - fn test_init_subcommand() { - let cli = parse(&["tnmsc", "init"]); - assert_eq!(resolve_command(&cli), ResolvedCommand::Init); - } - #[test] fn test_dry_run_subcommand() { let cli = parse(&["tnmsc", "dry-run"]); diff --git a/cli/src/commands/CommandRegistryFactory.ts b/cli/src/commands/CommandRegistryFactory.ts index a8704a9e..b54c340d 100644 --- a/cli/src/commands/CommandRegistryFactory.ts +++ b/cli/src/commands/CommandRegistryFactory.ts @@ -5,7 +5,6 @@ import {ConfigCommandFactory} from './factories/ConfigCommandFactory' import {DryRunCommandFactory} from './factories/DryRunCommandFactory' import {ExecuteCommandFactory} from './factories/ExecuteCommandFactory' import {HelpCommandFactory} from './factories/HelpCommandFactory' -import {InitCommandFactory} from './factories/InitCommandFactory' import {OutdatedCommandFactory} from './factories/OutdatedCommandFactory' import {PluginsCommandFactory} from './factories/PluginsCommandFactory' import {UnknownCommandFactory} from './factories/UnknownCommandFactory' @@ -25,7 +24,6 @@ export function createDefaultCommandRegistry(): CommandRegistry { registry.register(new UnknownCommandFactory()) registry.registerWithPriority(new OutdatedCommandFactory(), FactoryPriority.Subcommand) // Normal priority: subcommand-based commands - registry.registerWithPriority(new InitCommandFactory(), FactoryPriority.Subcommand) registry.registerWithPriority(new DryRunCommandFactory(), FactoryPriority.Subcommand) registry.registerWithPriority(new CleanCommandFactory(), FactoryPriority.Subcommand) registry.registerWithPriority(new PluginsCommandFactory(), FactoryPriority.Subcommand) diff --git a/cli/src/commands/InitCommand.test.ts b/cli/src/commands/InitCommand.test.ts deleted file mode 100644 index 2dda0df1..00000000 --- a/cli/src/commands/InitCommand.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type {CommandContext, CommandResult} from './Command' -import * as os from 'node:os' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {InitCommand} from './InitCommand' - -vi.mock('node:os') -vi.mock('@/ShadowSourceProject', () => ({ - generateShadowSourceProject: vi.fn(() => ({ - success: true, - rootPath: '/workspace/tnmsc-shadow', - createdDirs: [], - createdFiles: [], - existedDirs: ['/workspace/tnmsc-shadow'], - existedFiles: [] - })) -})) - -const MOCK_HOME = '/home/testuser' - -function makeCtx(overrides: Partial = {}): CommandContext { - const logger = { - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } - return { - logger, - outputPlugins: [], - collectedInputContext: {} as CommandContext['collectedInputContext'], - userConfigOptions: { - version: '2026.1', - 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: 'info', - fastCommandSeriesOptions: {}, - plugins: [] - }, - createCleanContext: vi.fn(), - createWriteContext: vi.fn(), - ...overrides - } as unknown as CommandContext -} - -describe('initCommand', () => { - beforeEach(() => vi.mocked(os.homedir).mockReturnValue(MOCK_HOME)) - - afterEach(() => vi.clearAllMocks()) - - it('has name "init"', () => expect(new InitCommand().name).toBe('init')) - - describe('execute — result shape', () => { - it('returns success=true when shadow project already exists', async () => { - const result: CommandResult = await new InitCommand().execute(makeCtx()) - expect(result.success).toBe(true) - }) - - it('returns numeric filesAffected and dirsAffected', async () => { - const result = await new InitCommand().execute(makeCtx()) - expect(typeof result.filesAffected).toBe('number') - expect(typeof result.dirsAffected).toBe('number') - }) - - it('returns a non-empty message string', async () => { - const result = await new InitCommand().execute(makeCtx()) - expect(typeof result.message).toBe('string') - expect(result.message!.length).toBeGreaterThan(0) - }) - }) -}) diff --git a/cli/src/commands/InitCommand.ts b/cli/src/commands/InitCommand.ts deleted file mode 100644 index ed34d196..00000000 --- a/cli/src/commands/InitCommand.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type {Command, CommandContext, CommandResult} from './Command' -import * as os from 'node:os' -import * as path from 'node:path' -import {generateShadowSourceProject} from '@/ShadowSourceProject' - -/** - * Resolve tilde and workspace path - */ -function resolveWorkspacePath(workspaceDir: string): string { - let resolved = workspaceDir - if (resolved.startsWith('~')) resolved = path.join(os.homedir(), resolved.slice(1)) - return path.normalize(resolved) -} - -/** - * Init command - initializes shadow source project directory structure - */ -export class InitCommand implements Command { - readonly name = 'init' - - async execute(ctx: CommandContext): Promise { - const {logger, userConfigOptions} = ctx - - logger.info('initializing shadow source project structure', {command: 'init'}) - - const workspaceDir = resolveWorkspacePath(userConfigOptions.workspaceDir) // Resolve workspace directory from user config - - const shadowSourceProjectDir = path.join(workspaceDir, userConfigOptions.shadowSourceProject.name) // Resolve shadow source project directory from config name - - const result = generateShadowSourceProject(shadowSourceProjectDir, {logger}) // Generate shadow source project structure - - 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)` - - logger.info('initialization complete', { - dirsCreated: result.createdDirs.length, - filesCreated: result.createdFiles.length, - dirsExisted: result.existedDirs.length, - filesExisted: result.existedFiles.length - }) - - return { - success: result.success, - filesAffected: result.createdFiles.length, - dirsAffected: result.createdDirs.length, - message - } - } -} diff --git a/cli/src/commands/factories/InitCommandFactory.ts b/cli/src/commands/factories/InitCommandFactory.ts deleted file mode 100644 index 971b757d..00000000 --- a/cli/src/commands/factories/InitCommandFactory.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type {Command} from '../Command' -import type {CommandFactory} from '../CommandFactory' -import type {ParsedCliArgs} from '@/pipeline' -import {InitCommand} from '../InitCommand' - -/** - * Factory for creating InitCommand - * Handles 'init' subcommand - */ -export class InitCommandFactory implements CommandFactory { - canHandle(args: ParsedCliArgs): boolean { - return args.subcommand === 'init' - } - - createCommand(_args: ParsedCliArgs): Command { - return new InitCommand() - } -} diff --git a/cli/src/commands/factories/index.ts b/cli/src/commands/factories/index.ts index 14e301f4..4c1e174e 100644 --- a/cli/src/commands/factories/index.ts +++ b/cli/src/commands/factories/index.ts @@ -24,9 +24,6 @@ export { export { HelpCommandFactory } from './HelpCommandFactory' -export { - InitCommandFactory -} from './InitCommandFactory' export { OutdatedCommandFactory } from './OutdatedCommandFactory' diff --git a/cli/src/commands/help.rs b/cli/src/commands/help.rs index 06b57cf6..1187915a 100644 --- a/cli/src/commands/help.rs +++ b/cli/src/commands/help.rs @@ -10,7 +10,6 @@ pub fn execute() -> ExitCode { println!(" (default) Sync AI memory and configuration files"); println!(" dry-run Preview changes without writing files"); println!(" clean Remove all generated output files"); - println!(" init Initialize directory structure"); println!(" config Set or show configuration values"); println!(" plugins List all registered plugins"); println!(" version Show version information"); diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts index 5a26714f..df10df91 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -22,8 +22,6 @@ export * from './DryRunOutputCommand' export * from './ExecuteCommand' export * from './factories' // Factory implementations export * from './HelpCommand' -export * from './InitCommand' - export * from './JsonOutputCommand' export * from './OutdatedCommand' export * from './PluginsCommand' diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs deleted file mode 100644 index 8e0095e0..00000000 --- a/cli/src/commands/init.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::process::ExitCode; - -use tnmsc_init_bundle::BUNDLES; -use tnmsc_logger::create_logger; - -pub fn execute() -> ExitCode { - let logger = create_logger("init", None); - let cwd = match std::env::current_dir() { - Ok(p) => p, - Err(e) => { - logger.error( - serde_json::Value::String(format!("Failed to get current directory: {e}")), - None, - ); - return ExitCode::FAILURE; - } - }; - - let bundles = BUNDLES; - if bundles.is_empty() { - logger.warn( - serde_json::Value::String( - "No init bundles available. Build the native library first.".into(), - ), - None, - ); - return ExitCode::SUCCESS; - } - - let mut written = 0usize; - for bundle in bundles { - let target = cwd.join(&bundle.path); - if let Some(parent) = target.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - logger.warn( - serde_json::Value::String(format!( - "Could not create directory {}: {e}", - parent.display() - )), - None, - ); - continue; - } - } - if target.exists() { - logger.debug( - serde_json::Value::String(format!("Skipping existing: {}", bundle.path)), - None, - ); - continue; - } - match std::fs::write(&target, &bundle.content) { - Ok(()) => { - logger.info( - serde_json::Value::String(format!("Created: {}", bundle.path)), - None, - ); - written += 1; - } - Err(e) => { - logger.warn( - serde_json::Value::String(format!("Failed to write {}: {e}", bundle.path)), - None, - ); - } - } - } - - logger.info( - serde_json::Value::String(format!("Init complete: {written} file(s) created")), - None, - ); - ExitCode::SUCCESS -} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 81d459fb..f2c252ff 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -1,7 +1,6 @@ pub mod help; pub mod version; pub mod outdated; -pub mod init; pub mod config_cmd; pub mod config_show; pub mod bridge; diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 3ef5b59a..11afd78b 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -1,6 +1,6 @@ //! tnmsc library — exposes core functionality for GUI backend direct invocation. //! -//! Pure Rust commands: version, load_config, config_show, init, outdated +//! Pure Rust commands: version, load_config, config_show, outdated //! Bridge commands (Node.js): run_bridge_command pub mod bridge; @@ -41,14 +41,6 @@ pub struct BridgeCommandResult { pub exit_code: i32, } -/// Result of the `init` command. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InitResult { - pub created_files: Vec, - pub skipped_files: Vec, -} - /// Result of the `outdated` check. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -78,33 +70,6 @@ pub fn config_show(cwd: &Path) -> Result { serde_json::to_string_pretty(&result.config).map_err(CliError::from) } -/// Run the `init` command: write bundled template files into `cwd`. -/// -/// Returns which files were created and which were skipped (already exist). -pub fn init(cwd: &Path) -> Result { - let bundles = tnmsc_init_bundle::BUNDLES; - let mut created_files = Vec::new(); - let mut skipped_files = Vec::new(); - - for bundle in bundles { - let target = cwd.join(bundle.path); - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent)?; - } - if target.exists() { - skipped_files.push(bundle.path.to_string()); - continue; - } - std::fs::write(&target, bundle.content)?; - created_files.push(bundle.path.to_string()); - } - - Ok(InitResult { - created_files, - skipped_files, - }) -} - /// Check whether the current CLI version is outdated against the npm registry. pub fn outdated() -> Result { let current = env!("CARGO_PKG_VERSION").to_string(); @@ -202,41 +167,6 @@ mod property_tests { prop_assert!(parsed.is_ok(), "config_show output should be valid JSON, got: {}", json_str); } - // ---- init(cwd) ---- - - /// For any fresh temporary directory, init returns Ok(InitResult) with - /// created_files and skipped_files as Vec. - #[test] - fn prop_init_returns_init_result_with_vec_fields(_seed in 0u64..50) { - let tmp = TempDir::new().expect("failed to create tempdir"); - let result = init(tmp.path()); - prop_assert!(result.is_ok(), "init should return Ok for a fresh tempdir, got: {:?}", result.err()); - let init_result = result.unwrap(); - let _total = init_result.created_files.len() + init_result.skipped_files.len(); - for f in &init_result.created_files { - prop_assert!(!f.is_empty(), "created file path should not be empty"); - } - for f in &init_result.skipped_files { - prop_assert!(!f.is_empty(), "skipped file path should not be empty"); - } - } - - /// Calling init twice on the same directory: second call should skip all files - /// that were created in the first call. - #[test] - fn prop_init_idempotent_skips_existing(_seed in 0u64..50) { - let tmp = TempDir::new().expect("failed to create tempdir"); - let first = init(tmp.path()).expect("first init should succeed"); - let second = init(tmp.path()).expect("second init should succeed"); - prop_assert!(second.created_files.is_empty(), - "second init should create no new files, but created: {:?}", second.created_files); - prop_assert_eq!( - first.created_files.len(), - second.skipped_files.len(), - "all files from first run should be skipped in second run" - ); - } - // ---- outdated() ---- /// outdated() always returns Ok(OutdatedResult) with current_version matching CARGO_PKG_VERSION. diff --git a/cli/src/main.rs b/cli/src/main.rs index 673e90a2..9ec491c2 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,6 +1,6 @@ //! tnmsc — Rust CLI entry point. //! -//! Pure Rust commands: help, version, outdated, init, config, config-show +//! Pure Rust commands: help, version, outdated, config, config-show //! Bridge commands (Node.js): execute, dry-run, clean, plugins mod cli; @@ -33,7 +33,6 @@ fn main() -> ExitCode { ResolvedCommand::Help => tnmsc::commands::help::execute(), ResolvedCommand::Version => tnmsc::commands::version::execute(), ResolvedCommand::Outdated => tnmsc::commands::outdated::execute(), - ResolvedCommand::Init => tnmsc::commands::init::execute(), ResolvedCommand::Config(pairs) => tnmsc::commands::config_cmd::execute(&pairs), ResolvedCommand::ConfigShow => tnmsc::commands::config_show::execute(), From ae5b91dd53f3df032088c8cb5633001d5608f06e Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 06:37:42 +0800 Subject: [PATCH 09/30] =?UTF-8?q?refactor(config):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE=E5=B9=B6=E5=8A=A0=E5=BC=BA?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构配置加载逻辑,移除默认配置的自动创建功能,改为严格要求用户提供有效配置 当配置不存在或无效时直接抛出错误而非静默使用默认值 新增对 aindex 配置格式的支持并自动转换为 shadowSourceProject 格式 更新相关测试用例以匹配新的严格验证行为 --- cli/src/ConfigLoader.test.ts | 51 ++------ cli/src/ConfigLoader.ts | 115 +++++++++--------- cli/src/PluginPipeline.test.ts | 9 -- cli/src/config.ts | 51 ++++---- .../plugin-shared/types/ConfigTypes.schema.ts | 76 +++++++++++- 5 files changed, 175 insertions(+), 127 deletions(-) diff --git a/cli/src/ConfigLoader.test.ts b/cli/src/ConfigLoader.test.ts index 2363ec0a..1287dc8a 100644 --- a/cli/src/ConfigLoader.test.ts +++ b/cli/src/ConfigLoader.test.ts @@ -84,18 +84,15 @@ describe('configLoader', () => { expect(result.config.logLevel).toBe('debug') }) - it('should handle invalid JSON gracefully', () => { + it('should throw error for invalid JSON', () => { vi.mocked(fs.existsSync).mockReturnValue(true) vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }') const loader = new ConfigLoader() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(false) - expect(result.config).toEqual({}) + expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Invalid JSON') // Now throws error instead of returning empty config }) - it('should validate string fields', () => { + it('should throw error for invalid string fields', () => { const configContent = JSON.stringify({ // workspaceDir is invalid (number instead of string) workspaceDir: 123 }) @@ -104,13 +101,10 @@ describe('configLoader', () => { vi.mocked(fs.readFileSync).mockReturnValue(configContent) const loader = new ConfigLoader() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.workspaceDir).toBeUndefined() // Invalid field should be ignored + expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Config validation failed') // Now throws error instead of ignoring invalid field }) - it('should validate logLevel values', () => { + it('should throw error for invalid logLevel values', () => { const configContent = JSON.stringify({ // logLevel is invalid logLevel: 'invalid' }) @@ -119,10 +113,7 @@ describe('configLoader', () => { vi.mocked(fs.readFileSync).mockReturnValue(configContent) const loader = new ConfigLoader() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.logLevel).toBeUndefined() + expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Config validation failed') // Now throws error instead of ignoring invalid field }) it('should validate shadowSourceProject object', () => { @@ -157,10 +148,7 @@ describe('configLoader', () => { vi.mocked(fs.readFileSync).mockReturnValue(configContent) const loader = new ConfigLoader() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.shadowSourceProject).toBeUndefined() + expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Config validation failed') // Now throws error instead of returning config with undefined shadowSourceProject }) it('should validate profile object with arbitrary key-value pairs', () => { @@ -197,10 +185,7 @@ describe('configLoader', () => { vi.mocked(fs.readFileSync).mockReturnValue(configContent) const loader = new ConfigLoader() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.profile).toBeUndefined() + expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Config validation failed') // Now throws error instead of returning empty config }) it('should reject invalid profile (array)', () => { @@ -212,23 +197,16 @@ describe('configLoader', () => { vi.mocked(fs.readFileSync).mockReturnValue(configContent) const loader = new ConfigLoader() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.profile).toBeUndefined() + expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Config validation failed') // Now throws error instead of returning empty config }) }) describe('load', () => { - it('should return empty config when no files found', () => { + it('should throw error when no config files found', () => { vi.mocked(fs.existsSync).mockReturnValue(false) const loader = new ConfigLoader() - const result = loader.load(mockCwd) - - expect(result.found).toBe(false) - expect(result.config).toEqual({}) - expect(result.sources).toEqual([]) + expect(() => loader.load(mockCwd)).toThrow('No valid config file found') // Now throws error instead of returning empty config }) it('should merge configs with correct priority', () => { @@ -318,13 +296,10 @@ describe('configLoader', () => { }) describe('loadUserConfig helper', () => { - it('should use default loader', () => { + it('should throw error when no config found', () => { vi.mocked(fs.existsSync).mockReturnValue(false) - const result = loadUserConfig(mockCwd) - - expect(result.found).toBe(false) - expect(result.config).toEqual({}) + expect(() => loadUserConfig(mockCwd)).toThrow('No valid config file found') // Now throws error instead of returning empty config }) }) }) diff --git a/cli/src/ConfigLoader.ts b/cli/src/ConfigLoader.ts index d002b1db..51e7cd33 100644 --- a/cli/src/ConfigLoader.ts +++ b/cli/src/ConfigLoader.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import process from 'node:process' -import {createLogger, DEFAULT_USER_CONFIG, ZUserConfigFile} from '@truenine/plugin-shared' +import {convertUserConfigAindexToShadowSourceProject, createLogger, DEFAULT_USER_CONFIG, ZUserConfigFile} from '@truenine/plugin-shared' /** * Default config file name @@ -25,24 +25,12 @@ export function getGlobalConfigPath(): string { /** * Get default user config content * Uses build-time injected template from public/tnmsc.example.json + * @deprecated Config is now required - no default config is provided */ export function getDefaultUserConfig(): UserConfigFile { return {...DEFAULT_USER_CONFIG} } -/** - * Write global config file - */ -function writeGlobalConfig(config: UserConfigFile, logger: ILogger): void { - const configPath = getGlobalConfigPath() - const configDir = path.dirname(configPath) - - if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, {recursive: true}) // Ensure directory exists - - fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8') // Write with pretty formatting - logger.info('global config created', {path: configPath}) -} - /** * Validation result for global config */ @@ -106,8 +94,8 @@ export class ConfigLoader { return {config, source: resolvedPath, found: true} } catch (error) { - this.logger.warn('load failed', {path: resolvedPath, error}) - return {config: {}, source: null, found: false} + const errorMessage = error instanceof Error ? error.message : String(error) // Parse/validation failure - throw error instead of silently returning empty config + throw new Error(`Failed to load config from ${resolvedPath}: ${errorMessage}`) } } @@ -120,13 +108,17 @@ export class ConfigLoader { if (result.found) loadedConfigs.push(result) } + if (loadedConfigs.length === 0) { // No config found - throw error instead of returning empty config + throw new Error(`No valid config file found. Searched: ${searchPaths.join(', ')}`) + } + const merged = this.mergeConfigs(loadedConfigs.map(r => r.config)) // Merge configs (first has highest priority) const sources = loadedConfigs.map(r => r.source).filter((s): s is string => s !== null) return { config: merged, sources, - found: loadedConfigs.length > 0 + found: true } } @@ -141,10 +133,12 @@ export class ConfigLoader { } const result = ZUserConfigFile.safeParse(parsed) - if (result.success) return result.data - const errors = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`) - this.logger.warn('validation warnings', {path: filePath, errors}) - return ZUserConfigFile.parse({}) // return empty valid config on partial failure + if (result.success) { + return convertUserConfigAindexToShadowSourceProject(result.data) // Convert aindex format to shadowSourceProject format if needed + } + + const errors = result.error.issues.map((i: {path: (string | number)[], message: string}) => `${i.path.join('.')}: ${i.message}`) // Validation failed - throw error instead of returning empty config + throw new Error(`Config validation failed in ${filePath}:\n${errors.join('\n')}`) } private mergeConfigs(configs: UserConfigFile[]): UserConfigFile { @@ -224,56 +218,76 @@ export function loadUserConfig(cwd?: string): MergedConfigResult { /** * Validate global config file strictly. - * - If config doesn't exist: create default config, log warn, continue - * - If config is invalid (parse error or validation error): delete and recreate, log error, exit + * - If config doesn't exist: return invalid result (do not auto-create) + * - If config is invalid (parse error or validation error): return invalid result (do not recreate) * * @returns Validation result indicating whether program should continue or exit */ -export function validateAndEnsureGlobalConfig(): GlobalConfigValidationResult { +export function validateGlobalConfig(): GlobalConfigValidationResult { const logger = createLogger('ConfigLoader') const configPath = getGlobalConfigPath() - if (!fs.existsSync(configPath)) { // Check if config file exists - logger.warn('global config not found, creating default config', {path: configPath}) - writeGlobalConfig(getDefaultUserConfig(), logger) + if (!fs.existsSync(configPath)) { // Check if config file exists - do not auto-create + const error = `Global config not found at ${configPath}. Please create it manually.` + logger.error(error) return { - valid: true, + valid: false, exists: false, - errors: [], - shouldExit: false + errors: [error], + shouldExit: true } } - let content: string // Try to read and parse config + let content: string try { content = fs.readFileSync(configPath, 'utf8') } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logger.error('failed to read global config', {path: configPath, error: errorMessage}) - return recreateConfigAndExit(configPath, logger, [`Failed to read config: ${errorMessage}`]) + return { + valid: false, + exists: true, + errors: [`Failed to read config: ${errorMessage}`], + shouldExit: true + } } - let parsed: unknown // Try to parse JSON + let parsed: unknown try { parsed = JSON.parse(content) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logger.error('invalid JSON in global config', {path: configPath, error: errorMessage}) - return recreateConfigAndExit(configPath, logger, [`Invalid JSON: ${errorMessage}`]) + return { + valid: false, + exists: true, + errors: [`Invalid JSON: ${errorMessage}`], + shouldExit: true + } } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { // Validate structure + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { logger.error('global config must be a JSON object', {path: configPath}) - return recreateConfigAndExit(configPath, logger, ['Config must be a JSON object']) + return { + valid: false, + exists: true, + errors: ['Config must be a JSON object'], + shouldExit: true + } } - const zodResult = ZUserConfigFile.safeParse(parsed) // Validate fields with Zod + const zodResult = ZUserConfigFile.safeParse(parsed) if (!zodResult.success) { - const errors = zodResult.error.issues.map(i => `${i.path.join('.')}: ${i.message}`) + const errors = zodResult.error.issues.map((i: {path: (string | number)[], message: string}) => `${i.path.join('.')}: ${i.message}`) for (const err of errors) logger.error('config validation error', {path: configPath, error: err}) - return recreateConfigAndExit(configPath, logger, errors) + return { + valid: false, + exists: true, + errors, + shouldExit: true + } } return { @@ -285,24 +299,9 @@ export function validateAndEnsureGlobalConfig(): GlobalConfigValidationResult { } /** - * Delete invalid config, recreate with defaults, and return exit result + * @deprecated Use validateGlobalConfig() instead. This function is kept for backward compatibility + * but no longer auto-creates default config. */ -function recreateConfigAndExit(configPath: string, logger: ILogger, errors: string[]): GlobalConfigValidationResult { - try { - fs.unlinkSync(configPath) - logger.info('deleted invalid config', {path: configPath}) - } - catch { - logger.warn('failed to delete invalid config', {path: configPath}) - } - - writeGlobalConfig(getDefaultUserConfig(), logger) - logger.error('recreated default config, please review and restart', {path: configPath}) - - return { - valid: false, - exists: true, - errors, - shouldExit: true - } +export function validateAndEnsureGlobalConfig(): GlobalConfigValidationResult { + return validateGlobalConfig() } diff --git a/cli/src/PluginPipeline.test.ts b/cli/src/PluginPipeline.test.ts index fc1cfd19..88e7b99f 100644 --- a/cli/src/PluginPipeline.test.ts +++ b/cli/src/PluginPipeline.test.ts @@ -11,7 +11,6 @@ import { DryRunOutputCommand, ExecuteCommand, HelpCommand, - InitCommand, UnknownCommand } from '@/commands' import {parseArgs, PluginPipeline, resolveCommand, resolveLogLevel} from '@/PluginPipeline' @@ -776,14 +775,6 @@ describe('resolveCommand', () => { }) }) - describe('init command', () => { - it('should return InitCommand for init subcommand', () => { - const args = createParsedArgs({subcommand: 'init'}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(InitCommand) - }) - }) - describe('dry-run command', () => { it('should return DryRunOutputCommand for dry-run subcommand', () => { const args = createParsedArgs({subcommand: 'dry-run'}) diff --git a/cli/src/config.ts b/cli/src/config.ts index 9fdb0b27..3b52f910 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -2,9 +2,9 @@ import type {CollectedInputContext, ConfigLoaderOptions, FastCommandSeriesOption 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 {createLogger, PluginKind} from '@truenine/plugin-shared' import glob from 'fast-glob' -import {loadUserConfig, validateAndEnsureGlobalConfig} from './ConfigLoader' +import {loadUserConfig, validateGlobalConfig} from './ConfigLoader' import {PluginPipeline} from './PluginPipeline' import {checkVersionControl} from './ShadowSourceProject' @@ -29,9 +29,9 @@ const DEFAULT_SHADOW_SOURCE_PROJECT: Required = { } const DEFAULT_OPTIONS: Required = { - version: DEFAULT_USER_CONFIG.version ?? '0.0.0', - workspaceDir: DEFAULT_USER_CONFIG.workspaceDir ?? '~/project', - logLevel: DEFAULT_USER_CONFIG.logLevel ?? 'info', + version: '0.0.0', + workspaceDir: '~/project', + logLevel: 'info', shadowSourceProject: DEFAULT_SHADOW_SOURCE_PROJECT, fastCommandSeriesOptions: {}, plugins: [] @@ -164,8 +164,12 @@ function isDefineConfigOptions(options: PluginOptions | DefineConfigOptions): op * @param options - Plugin options or DefineConfigOptions */ export async function defineConfig(options: PluginOptions | DefineConfigOptions = {}): Promise { - const validationResult = validateAndEnsureGlobalConfig() // Validate and ensure global config exists - if (validationResult.shouldExit) process.exit(1) + const validationResult = validateGlobalConfig() // Validate global config exists and is valid - do not auto-create + if (!validationResult.valid) { + const logger = createLogger('defineConfig') // Log all errors before exiting + for (const error of validationResult.errors) logger.error(error) + process.exit(1) + } let shouldLoadUserConfig: boolean, // Normalize options cwd: string | undefined, @@ -185,27 +189,32 @@ export async function defineConfig(options: PluginOptions | DefineConfigOptions let userConfigFile: UserConfigFile | undefined if (shouldLoadUserConfig) { - const userConfigResult = loadUserConfig(cwd) - userConfigFound = userConfigResult.found - userConfigSources = userConfigResult.sources - if (userConfigResult.found) { - userConfigOptions = userConfigToPluginOptions(userConfigResult.config) - userConfigFile = userConfigResult.config + try { + const userConfigResult = loadUserConfig(cwd) + userConfigFound = userConfigResult.found + userConfigSources = userConfigResult.sources + if (userConfigResult.found) { + userConfigOptions = userConfigToPluginOptions(userConfigResult.config) + userConfigFile = userConfigResult.config + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) // Config loading failed - throw error instead of using defaults + throw new Error(`Failed to load user config: ${errorMessage}`) } } + if (!userConfigFound) { // Require user config to be found - no fallback to defaults + throw new Error( + 'No user config found. Please create ~/.aindex/.tnmsc.json or a .tnmsc.json in your working directory.' + ) + } + const mergedOptions = mergeConfig(userConfigOptions, pluginOptions) // Merge: defaults <- user config <- programmatic options const {plugins = [], logLevel} = mergedOptions const logger = createLogger('defineConfig', logLevel) - if (userConfigFound) logger.info('user config loaded', {sources: userConfigSources}) // Log configuration loading info - else { - logger.info('no user config found, using defaults', { - workspaceDir: DEFAULT_OPTIONS.workspaceDir, - shadowSourceProjectName: DEFAULT_OPTIONS.shadowSourceProject.name, - logLevel: DEFAULT_OPTIONS.logLevel - }) - } + logger.info('user config loaded', {sources: userConfigSources}) const baseCtx: Omit = { // Base context without dependencyContext, globalScope, scopeRegistry (will be provided by pipeline) logger, diff --git a/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts b/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts index 33925197..b99f349f 100644 --- a/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts +++ b/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts @@ -26,6 +26,53 @@ export const ZShadowSourceProjectConfig = z.object({ project: ZShadowSourceProjectDirPair }) +/** + * Zod schema for the aindex configuration (new format). + * This is the user-facing configuration format in ~/.aindex/.tnmsc.json + * All paths are relative to `/`. + */ +export const ZAindexConfig = z.object({ + /** Name of the aindex configuration */ + name: z.string(), + /** Skills module paths */ + skills: ZShadowSourceProjectDirPair, + /** Commands module paths (maps to fastCommand) */ + commands: ZShadowSourceProjectDirPair, + /** Sub-agents module paths (maps to subAgent) */ + subAgents: ZShadowSourceProjectDirPair, + /** Rules module paths (maps to rule) */ + rules: ZShadowSourceProjectDirPair, + /** Global prompt file paths (maps to globalMemory) */ + globalPrompt: ZShadowSourceProjectDirPair, + /** Workspace prompt file paths (maps to workspaceMemory) */ + workspacePrompt: ZShadowSourceProjectDirPair, + /** Application module paths (maps to project) */ + app: ZShadowSourceProjectDirPair, + /** Extension module paths (currently ignored by plugins) */ + ext: ZShadowSourceProjectDirPair.optional(), + /** Architecture module paths (currently ignored by plugins) */ + arch: ZShadowSourceProjectDirPair.optional() +}) + +/** + * Convert aindex config format to shadowSourceProject format. + * This provides compatibility between user-facing config and plugin system. + */ +function convertAindexToShadowSourceProject( + aindex: z.infer +): z.infer { + return { + name: aindex.name, + skill: aindex.skills, + fastCommand: aindex.commands, + subAgent: aindex.subAgents, + rule: aindex.rules, + globalMemory: aindex.globalPrompt, + workspaceMemory: aindex.workspacePrompt, + project: aindex.app + } +} + /** * Zod schema for per-plugin fast command series override options */ @@ -54,17 +101,43 @@ export const ZUserProfile = z.object({ /** * Zod schema for the user configuration file (.tnmsc.json). - * All fields are optional — missing fields use default values. + * Supports both new 'aindex' format and legacy 'shadowSourceProject' format. + * Note: The conversion from aindex to shadowSourceProject is done in ConfigLoader, not here, + * to avoid circular type dependencies. */ export const ZUserConfigFile = z.object({ version: z.string().optional(), workspaceDir: z.string().optional(), + /** New format: aindex configuration */ + aindex: ZAindexConfig.optional(), + /** Legacy format: shadow source project configuration */ shadowSourceProject: ZShadowSourceProjectConfig.optional(), logLevel: z.enum(['trace', 'debug', 'info', 'warn', 'error']).optional(), fastCommandSeriesOptions: ZFastCommandSeriesOptions.optional(), profile: ZUserProfile.optional() }) +/** + * Convert UserConfigFile with aindex format to include shadowSourceProject. + * This function should be called after ZUserConfigFile.parse() to ensure valid input. + */ +export function convertUserConfigAindexToShadowSourceProject( + config: z.infer +): z.infer { + if (config.shadowSourceProject != null) { // If shadowSourceProject is explicitly provided, use it directly + return config + } + + if (config.aindex != null) { // If aindex is provided, convert it to shadowSourceProject + return { + ...config, + shadowSourceProject: convertAindexToShadowSourceProject(config.aindex) + } + } + + return config // Neither format provided - return as-is +} + /** * Zod schema for MCP project config */ @@ -104,6 +177,7 @@ export const ZConfigLoaderOptions = z.object({ export type ShadowSourceProjectDirPair = z.infer export type ShadowSourceProjectConfig = z.infer +export type AindexConfig = z.infer export type FastCommandSeriesPluginOverride = z.infer export type FastCommandSeriesOptions = z.infer export type UserConfigFile = z.infer From 50d562bf98d13b0fde6465ab25933236e789f296 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 07:57:33 +0800 Subject: [PATCH 10/30] =?UTF-8?q?feat(plugin-input-orphan-cleanup-effect):?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=E6=BA=90?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从用户配置中获取源文件路径,替代硬编码路径,提高灵活性 --- .../OrphanFileCleanupEffectInputPlugin.ts | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts b/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts index b8a8f8c9..7121b029 100644 --- a/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts +++ b/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts @@ -16,7 +16,7 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { } private async cleanupOrphanFiles(ctx: InputEffectContext): Promise { - const {fs, path, shadowProjectDir, dryRun, logger} = ctx + const {fs, path, shadowProjectDir, dryRun, logger, userConfigOptions} = ctx const distDir = path.join(shadowProjectDir, 'dist') @@ -34,11 +34,19 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { } } + const shadowConfig = userConfigOptions.shadowSourceProject // Get source paths from config, fallback to defaults + const srcPaths: Record = { + skills: shadowConfig?.skill?.src ?? 'src/skills', + commands: shadowConfig?.fastCommand?.src ?? 'src/commands', + agents: shadowConfig?.subAgent?.src ?? 'src/agents', + app: shadowConfig?.project?.src ?? 'app' + } + 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) + if (fs.existsSync(distSubDirPath)) this.cleanupDirectory(ctx, distSubDirPath, subDir, srcPaths[subDir]!, deletedFiles, deletedDirs, errors, dryRun ?? false) } const hasErrors = errors.length > 0 @@ -59,6 +67,7 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { ctx: InputEffectContext, distDirPath: string, dirType: string, + srcPath: string, deletedFiles: string[], deletedDirs: string[], errors: {path: string, error: Error}[], @@ -80,11 +89,11 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { const entryPath = path.join(distDirPath, entry.name) if (entry.isDirectory()) { - this.cleanupDirectory(ctx, entryPath, dirType, deletedFiles, deletedDirs, errors, dryRun) + this.cleanupDirectory(ctx, entryPath, dirType, srcPath, deletedFiles, deletedDirs, errors, dryRun) this.removeEmptyDirectory(ctx, entryPath, deletedDirs, errors, dryRun) } else if (entry.isFile()) { - const isOrphan = this.isOrphanFile(ctx, entryPath, dirType, shadowProjectDir) + const isOrphan = this.isOrphanFile(ctx, entryPath, dirType, srcPath, shadowProjectDir) if (isOrphan) { if (dryRun) { @@ -110,6 +119,7 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { ctx: InputEffectContext, distFilePath: string, dirType: string, + srcPath: string, shadowProjectDir: string ): boolean { const {fs, path} = ctx @@ -123,14 +133,13 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { const baseName = fileName.replace(/\.mdx$/, '') if (isMdxFile) { - const possibleSrcPaths = this.getPossibleSourcePaths(path, shadowProjectDir, dirType, baseName, relativeDir) + const possibleSrcPaths = this.getPossibleSourcePaths(path, shadowProjectDir, dirType, srcPath, 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)) + possibleSrcPaths.push(path.join(shadowProjectDir, srcPath, relativeFromType)) return !possibleSrcPaths.some(srcPath => fs.existsSync(srcPath)) } @@ -139,42 +148,48 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { nodePath: typeof import('node:path'), shadowProjectDir: string, dirType: string, + srcPath: 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 'skills': { // Skill structure: dist/skills/{name}/{file}.mdx -> src/skills/{name}/{file}.cn.mdx + const skillParts = relativeDir === '.' ? [baseName] : relativeDir.split(nodePath.sep) + const skillName = skillParts[0] ?? baseName + const remainingPath = relativeDir === '.' ? '' : relativeDir.slice(skillName.length + 1) // +1 for separator + + if (remainingPath !== '') return [nodePath.join(shadowProjectDir, srcPath, skillName, remainingPath, `${baseName}.cn.mdx`)] // Nested child doc + if (baseName === 'skill' || baseName === 'SKILL') { // Main skill file + return [ + nodePath.join(shadowProjectDir, srcPath, skillName, 'skill.cn.mdx'), + nodePath.join(shadowProjectDir, srcPath, skillName, 'SKILL.cn.mdx') + ] + } + return [nodePath.join(shadowProjectDir, srcPath, skillName, `${baseName}.cn.mdx`)] // Direct child doc in skill root + } case 'commands': return relativeDir === '.' ? [ - nodePath.join(shadowProjectDir, 'src', 'commands', `${baseName}.cn.mdx`) + nodePath.join(shadowProjectDir, srcPath, `${baseName}.cn.mdx`) ] : [ - nodePath.join(shadowProjectDir, 'src', 'commands', relativeDir, `${baseName}.cn.mdx`) + nodePath.join(shadowProjectDir, srcPath, relativeDir, `${baseName}.cn.mdx`) ] case 'agents': return relativeDir === '.' ? [ - nodePath.join(shadowProjectDir, 'src', 'agents', `${baseName}.cn.mdx`) + nodePath.join(shadowProjectDir, srcPath, `${baseName}.cn.mdx`) ] : [ - nodePath.join(shadowProjectDir, 'src', 'agents', relativeDir, `${baseName}.cn.mdx`) + nodePath.join(shadowProjectDir, srcPath, relativeDir, `${baseName}.cn.mdx`) ] case 'app': return relativeDir === '.' ? [ - nodePath.join(shadowProjectDir, 'app', `${baseName}.cn.mdx`) + nodePath.join(shadowProjectDir, srcPath, `${baseName}.cn.mdx`) ] : [ - nodePath.join(shadowProjectDir, 'app', relativeDir, `${baseName}.cn.mdx`) + nodePath.join(shadowProjectDir, srcPath, relativeDir, `${baseName}.cn.mdx`) ] default: return [] } From bb9c1a0bcb8be4c450808ae575987a2eb59479aa Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 08:55:50 +0800 Subject: [PATCH 11/30] =?UTF-8?q?feat(=E6=8F=92=E4=BB=B6=E8=BE=93=E5=85=A5?= =?UTF-8?q?):=20=E6=B7=BB=E5=8A=A0=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=94=AF=E6=8C=81=E5=B9=B6=E9=87=8D=E6=9E=84=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构输入插件以支持多语言提示,新增LocalizedTypes定义和LocalizedPromptReader类 将技能、子代理、快速命令和规则输入插件迁移到新的本地化架构 添加向后兼容性支持,同时引入新的prompts上下文和promptIndex --- .../SkillInputPlugin.ts | 223 +++++++--- .../FastCommandInputPlugin.ts | 222 ++++------ .../plugin-input-rule/RuleInputPlugin.ts | 324 +++++++++------ .../LocalizedPromptReader.ts | 393 ++++++++++++++++++ cli/src/plugins/plugin-input-shared/index.ts | 9 + .../SubAgentInputPlugin.ts | 223 ++++------ .../plugins/plugin-shared/types/InputTypes.ts | 23 +- .../plugin-shared/types/LocalizedTypes.ts | 224 ++++++++++ cli/src/plugins/plugin-shared/types/index.ts | 1 + 9 files changed, 1163 insertions(+), 479 deletions(-) create mode 100644 cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts create mode 100644 cli/src/plugins/plugin-shared/types/LocalizedTypes.ts diff --git a/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts b/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts index 788c440a..07293329 100644 --- a/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts +++ b/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts @@ -1,18 +1,27 @@ -import type {CollectedInputContext, ILogger, InputPluginContext, McpServerConfig, SkillMcpConfig, SkillPrompt, SkillYAMLFrontMatter} from '@truenine/plugin-shared' -import type {Dirent} from 'node:fs' +import type { + CollectedInputContext, + ILogger, + InputPluginContext, + LocalizedPrompt, + LocalizedSkillPrompt, + McpServerConfig, + SkillMcpConfig, + SkillPrompt, + SkillYAMLFrontMatter +} from '@truenine/plugin-shared' import type {ResourceScanResult} from './ResourceProcessor' 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 {AbstractInputPlugin, createLocalizedPromptReader} from '@truenine/plugin-input-shared' import {FilePathKind, PromptKind, validateSkillMetadata} from '@truenine/plugin-shared' import {ResourceProcessor} from './ResourceProcessor' export { getResourceCategory, isBinaryResourceExtension -} from './config/fileTypes' // Re-export for backward compatibility +} from './config/fileTypes' /** * Read MCP configuration from mcp.json file @@ -53,7 +62,75 @@ function readMcpConfig( } /** - * Process skill file and extract metadata + * Create SkillPrompt from compiled content + */ +async function createSkillPrompt( + content: string, + _locale: 'zh' | 'en', + name: string, + skillDir: string, + skillAbsoluteDir: string, + ctx: InputPluginContext, + mcpConfig?: SkillMcpConfig, + childDocs: SkillPrompt['childDocs'] = [], + resources: SkillPrompt['resources'] = [], + seriName?: string | string[] | null +): Promise { + const {logger, globalScope, fs} = ctx + + const srcFilePath = path.join(skillAbsoluteDir, 'skill.cn.mdx') // Find the source file to get metadata + let rawContent = content + let parsed: ReturnType> | undefined + + if (fs.existsSync(srcFilePath)) { + try { + rawContent = fs.readFileSync(srcFilePath, 'utf8') + parsed = parseMarkdown(rawContent) + + const compileResult = await mdxToMd(rawContent, { // Re-compile if reading from source + globalScope, + extractMetadata: true, + basePath: skillAbsoluteDir + }) + + content = transformMdxReferencesToMd(compileResult.content) + } + catch (e) { + logger.warn('failed to recompile skill from source', {skill: name, error: e}) + } + } + + const mergedFrontMatter: SkillYAMLFrontMatter = { + ...parsed?.yamlFrontMatter ?? {}, + name, + description: '' + } as SkillYAMLFrontMatter + + return { // Build result with all optional fields using spread + type: PromptKind.Skill, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + yamlFrontMatter: mergedFrontMatter, + markdownAst: parsed?.markdownAst, + markdownContents: parsed?.markdownContents ?? [], + dir: { + pathKind: FilePathKind.Relative, + path: name, + basePath: skillDir, + getDirectoryName: () => name, + getAbsolutePath: () => path.join(skillDir, name) + }, + ...parsed?.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, + ...mcpConfig != null && {mcpConfig}, + ...childDocs != null && childDocs.length > 0 && {childDocs}, + ...resources != null && resources.length > 0 && {resources}, + ...seriName != null && {seriName} + } as SkillPrompt +} + +/** + * Process skill file and extract metadata (legacy, for backward compatibility) */ async function processSkillFile( skillFilePath: string, @@ -149,23 +226,6 @@ async function processSkillFile( } } -/** - * Check if directory entry is a valid skill - */ -/** - * Check if directory entry is a valid skill - */ -function isValidSkillDirectory( - entry: Dirent, - skillDir: string, - fs: typeof import('node:fs') -): boolean { - if (!entry.isDirectory()) return false - - const skillFilePath = path.join(skillDir, entry.name, 'skill.mdx') - return fs.existsSync(skillFilePath) && fs.statSync(skillFilePath).isFile() -} - export class SkillInputPlugin extends AbstractInputPlugin { constructor() { super('SkillInputPlugin') @@ -186,51 +246,104 @@ export class SkillInputPlugin extends AbstractInputPlugin { currentRelativePath: string = '' ): ResourceScanResult { const processor = new ResourceProcessor({fs, logger, skillDir}) - return processor.scanSkillDirectory(skillDir, currentRelativePath) // When called recursively, currentRelativePath is set and we join paths // When called from tests with empty currentRelativePath, we need to use skillDir as currentDir + return processor.scanSkillDirectory(skillDir, currentRelativePath) } async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger} = ctx + const {userConfigOptions: options, logger, fs, path: pathModule, globalScope} = ctx const {shadowProjectDir} = this.resolveBasePaths(options) - const skillDir = this.resolveShadowPath(options.shadowSourceProject.skill.dist, shadowProjectDir) - const skills: SkillPrompt[] = [] + const srcSkillDir = this.resolveShadowPath(options.shadowSourceProject.skill.src, shadowProjectDir) // Get both src and dist paths + const distSkillDir = this.resolveShadowPath(options.shadowSourceProject.skill.dist, shadowProjectDir) + + const legacySkills: SkillPrompt[] = [] + + const reader = createLocalizedPromptReader(fs, pathModule, logger, globalScope) // Use LocalizedPromptReader for new architecture + + const {prompts: localizedSkills, errors} = await reader.readDirectoryStructure( + srcSkillDir, + distSkillDir, + { + kind: PromptKind.Skill, + entryFileName: 'skill', + localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, + isDirectoryStructure: true, + createPrompt: async (content, locale, name) => { + const skillSrcDir = pathModule.join(srcSkillDir, name) // Get extras from source directory + + const processor = new ResourceProcessor({fs, logger, skillDir: skillSrcDir}) + const {childDocs, resources} = processor.scanSkillDirectory(skillSrcDir) + const mcpConfig = readMcpConfig(skillSrcDir, fs, logger) + + return createSkillPrompt( + content, + locale, + name, + distSkillDir, + skillSrcDir, + ctx, + mcpConfig, + childDocs, + resources + ) + } + } + ) - if (!(ctx.fs.existsSync(skillDir) && ctx.fs.statSync(skillDir).isDirectory())) { // Early return if skill directory doesn't exist - return {skills} + for (const error of errors) { // Log errors but don't fail + logger.warn('Failed to read skill', {path: error.path, phase: error.phase, error: error.error}) } - let entries: Dirent[] - try { - entries = ctx.fs.readdirSync(skillDir, {withFileTypes: true}) - } - catch (e) { - logger.warn('failed to read skill directory', {skillDir, error: e}) - return {skills} + for (const localized of localizedSkills) { // Build legacy skills array from localized prompts (for backward compatibility) + const prompt = localized.dist?.prompt ?? localized.src.default.prompt // Prefer dist content, fallback to src.default + + if (prompt) legacySkills.push(prompt) } - for (const entry of entries) { - if (!isValidSkillDirectory(entry, skillDir, ctx.fs)) continue - - const entryName = entry.name - const skillFilePath = ctx.path.join(skillDir, entryName, 'skill.mdx') - const skillAbsoluteDir = ctx.path.join(skillDir, entryName) - - try { - const skill = await processSkillFile( - skillFilePath, - skillDir, - entryName, - skillAbsoluteDir, - ctx - ) - if (skill) skills.push(skill) - } - catch (e) { - logger.error('failed to parse skill', {file: skillFilePath, error: e}) + if (fs.existsSync(distSkillDir)) { // Also scan dist directory for skills that might not have src (edge case) + const distEntries = fs.readdirSync(distSkillDir, {withFileTypes: true}) + const existingNames = new Set(localizedSkills.map(s => s.name)) + + for (const entry of distEntries) { + if (!entry.isDirectory()) continue + if (existingNames.has(entry.name)) continue + + const entryName = entry.name + const skillFilePath = pathModule.join(distSkillDir, entryName, 'skill.mdx') + const skillAbsoluteDir = pathModule.join(distSkillDir, entryName) + + if (!fs.existsSync(skillFilePath)) continue + + try { + const skill = await processSkillFile( + skillFilePath, + distSkillDir, + entryName, + skillAbsoluteDir, + ctx + ) + if (skill) legacySkills.push(skill) + } + catch (e) { + logger.error('failed to parse skill', {file: skillFilePath, error: e}) + } } } - return {skills} + const promptIndex = new Map() // Build prompt index + for (const skill of localizedSkills) promptIndex.set(skill.name, skill) + + return { + prompts: { // New architecture - partial prompts context (other arrays filled by other plugins) + skills: localizedSkills as LocalizedSkillPrompt[], + commands: [], + subAgents: [], + rules: [], + readme: [] + }, + promptIndex, + + skills: legacySkills // Legacy (backward compatibility) + } } } diff --git a/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts b/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts index d9601fc9..49c1fa36 100644 --- a/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts +++ b/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts @@ -1,21 +1,19 @@ -import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' import type { CollectedInputContext, FastCommandPrompt, - FastCommandYAMLFrontMatter, InputPluginContext, - MetadataValidationResult, + Locale, + LocalizedFastCommandPrompt, 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 { + AbstractInputPlugin, + createLocalizedPromptReader +} from '@truenine/plugin-input-shared' import { FilePathKind, - PromptKind, - validateFastCommandMetadata + PromptKind } from '@truenine/plugin-shared' export interface SeriesInfo { @@ -23,21 +21,50 @@ export interface SeriesInfo { readonly commandName: string } -export class FastCommandInputPlugin extends BaseDirectoryInputPlugin { +export class FastCommandInputPlugin extends AbstractInputPlugin { constructor() { - super('FastCommandInputPlugin', {configKey: 'shadowSourceProject.fastCommand.dist'}) + super('FastCommandInputPlugin') } - protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + private getDistDir(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) - } + private createFastCommandPrompt( + content: string, + _locale: Locale, + name: string, + srcDir: string, + _distDir: string, + ctx: InputPluginContext, + _rawContent?: string + ): FastCommandPrompt { + const {path} = ctx + + const slashIndex = name.indexOf('/') + const parentDirName = slashIndex !== -1 ? name.slice(0, slashIndex) : void 0 + const fileName = slashIndex !== -1 ? name.slice(slashIndex + 1) : name - protected createResult(items: FastCommandPrompt[]): Partial { - return {fastCommands: items} + const seriesInfo = this.extractSeriesInfo(fileName, parentDirName) + + const filePath = path.join(srcDir, `${name}.cn.mdx`) + const entryName = `${name}.mdx` + + return { + type: PromptKind.FastCommand, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: { + pathKind: FilePathKind.Relative, + path: entryName, + basePath: srcDir, + getDirectoryName: () => entryName.replace(/\.mdx$/, ''), + getAbsolutePath: () => filePath + }, + ...seriesInfo.series != null && {series: seriesInfo.series}, + commandName: seriesInfo.commandName + } as FastCommandPrompt } extractSeriesInfo(fileName: string, parentDirName?: string): SeriesInfo { @@ -61,140 +88,53 @@ export class FastCommandInputPlugin extends BaseDirectoryInputPlugin> { - const {userConfigOptions: options, logger, path, fs} = ctx + const {userConfigOptions: options, logger, path, fs, globalScope} = 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}) - } - } + const srcDir = this.resolveShadowPath(options.shadowSourceProject.fastCommand.src, resolvedPaths.shadowProjectDir) + const distDir = this.getDistDir(options, resolvedPaths) + + const reader = createLocalizedPromptReader(fs, path, logger, globalScope) + + const {prompts: localizedCommands, errors} = await reader.readFlatFiles( + srcDir, + distDir, + { + kind: PromptKind.FastCommand, + localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, + isDirectoryStructure: false, + createPrompt: async (content, locale, name) => this.createFastCommandPrompt( + content, + locale, + name, + srcDir, + distDir, + ctx + ) } - } catch (e) { - logger.error(`Failed to scan directory at ${targetDir}`, {error: e}) - } + ) - return this.createResult(items) - } + for (const error of errors) logger.warn('Failed to read command', {path: error.path, phase: error.phase, error: error.error}) - 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 + const legacyCommands: FastCommandPrompt[] = [] + for (const localized of localizedCommands) { + const prompt = localized.dist?.prompt ?? localized.src.default.prompt + if (prompt) legacyCommands.push(prompt) } - } - 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 + const promptIndex = new Map() + for (const cmd of localizedCommands) promptIndex.set(cmd.name, cmd) 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 + prompts: { + skills: [], + commands: localizedCommands, + subAgents: [], + rules: [], + readme: [] }, - ...seriesInfo.series != null && {series: seriesInfo.series}, - commandName: seriesInfo.commandName, - ...seriName != null && {seriName}, - rawMdxContent: rawContent + promptIndex, + fastCommands: legacyCommands } } } diff --git a/cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts b/cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts index 28842b48..9f3aff42 100644 --- a/cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts +++ b/cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts @@ -1,176 +1,228 @@ import type { CollectedInputContext, InputPluginContext, - MetadataValidationResult, + LocalizedRulePrompt, PluginOptions, ResolvedBasePaths, RulePrompt, - RuleScope, - RuleYAMLFrontMatter + RuleScope } 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 { + AbstractInputPlugin, + createLocalizedPromptReader +} from '@truenine/plugin-input-shared' import { FilePathKind, - PromptKind, - validateRuleMetadata + PromptKind } from '@truenine/plugin-shared' -export class RuleInputPlugin extends BaseDirectoryInputPlugin { +export class RuleInputPlugin extends AbstractInputPlugin { constructor() { - super('RuleInputPlugin', {configKey: 'shadowSourceProject.rule.dist'}) + super('RuleInputPlugin') } - protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + private getDistDir(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) + private getSrcDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + return this.resolveShadowPath(options.shadowSourceProject.rule.src, resolvedPaths.shadowProjectDir) } - protected createResult(items: RulePrompt[]): Partial { - return {rules: items} - } + override async collect(ctx: InputPluginContext): Promise> { + const {userConfigOptions: options, logger, path, fs, globalScope} = ctx + const resolvedPaths = this.resolveBasePaths(options) - 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 + const srcDir = this.getSrcDir(options, resolvedPaths) + const distDir = this.getDistDir(options, resolvedPaths) + + const reader = createLocalizedPromptReader(fs, path, logger, globalScope) // Use LocalizedPromptReader for flat file structure with new architecture + + const {prompts: localizedRulesFromSrc, errors} = await reader.readFlatFiles( // First, try to read from src directory structure (new way) + srcDir, + distDir, + { + kind: PromptKind.Rule, + localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, + isDirectoryStructure: false, + createPrompt: async (content, _locale, name) => { + const srcFilePath = path.join(srcDir, `${name}.cn.mdx`) // For src files, extract metadata from the compiled content's source + let globs: readonly string[] = [] + let scope: RuleScope = 'project' + let seriName: string | undefined, + yamlFrontMatter: Record | undefined, + rawFrontMatter: string | undefined - 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 - } - } + try { + const rawContent = fs.readFileSync(srcFilePath, 'utf8') + const {yamlFrontMatter: yfm, rawFrontMatter: rfm} = parseMarkdown(rawContent) + if (yfm) { + yamlFrontMatter = yfm + rawFrontMatter = rfm + globs = (yfm['globs'] as string[]) ?? [] + scope = (yfm['scope'] as RuleScope) ?? 'project' + seriName = yfm['seriName'] as string | undefined + } + } + catch { /* Ignore errors */ } + + const rulePrompt = { + type: PromptKind.Rule, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: { + pathKind: FilePathKind.Relative, + path: `${name}.mdx`, + basePath: distDir, + getDirectoryName: () => name.split('/').pop() ?? name, + getAbsolutePath: () => path.join(distDir, `${name}.mdx`) + }, + series: name.includes('/') ? name.split('/')[0] ?? '' : '', + ruleName: name.split('/').pop() ?? name, + globs, + scope, + markdownContents: [] + } as RulePrompt + + if (yamlFrontMatter != null) Object.assign(rulePrompt, {yamlFrontMatter}) + if (rawFrontMatter != null) Object.assign(rulePrompt, {rawFrontMatter}) + if (seriName != null) Object.assign(rulePrompt, {seriName}) + + return rulePrompt + } + } + ) - override async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger, path, fs} = ctx - const resolvedPaths = this.resolveBasePaths(options) + const legacyRules: RulePrompt[] = [] // Also scan dist directory for legacy nested structure (backward compatibility) + const localizedRules: LocalizedRulePrompt[] = [...localizedRulesFromSrc] + + if (fs.existsSync(distDir)) { + try { + const entries = fs.readdirSync(distDir, {withFileTypes: true}) - const targetDir = this.getTargetDir(options, resolvedPaths) - const items: RulePrompt[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue - if (!(fs.existsSync(targetDir) && fs.statSync(targetDir).isDirectory())) return this.createResult(items) + const seriesName = entry.name + const seriesDir = path.join(distDir, seriesName) + + const alreadyProcessed = localizedRulesFromSrc.some(r => r.name.startsWith(`${seriesName}/`)) // Skip if already processed from src + if (alreadyProcessed) continue - 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) + const files = fs.readdirSync(seriesDir, {withFileTypes: true}) + + for (const file of files) { + if (!file.isFile() || !file.name.endsWith('.mdx')) continue + + const baseName = file.name.slice(0, -'.mdx'.length) + const name = `${seriesName}/${baseName}` + const distFilePath = path.join(seriesDir, file.name) + + if (localizedRulesFromSrc.some(r => r.name === name)) continue // Check if we already have this from src + + try { + const rawContent = fs.readFileSync(distFilePath, 'utf8') + const parsed = parseMarkdown(rawContent) + + const content = globalScope != null ? await mdxToMd(rawContent, {globalScope, basePath: seriesDir}) : parsed.contentWithoutFrontMatter ?? rawContent // Compile if needed + + const {yamlFrontMatter} = parsed + const globs = (yamlFrontMatter?.['globs'] as string[]) ?? [] + const scope = (yamlFrontMatter?.['scope'] as RuleScope) ?? 'project' + const seriName = yamlFrontMatter?.['seriName'] as string | undefined + + const rulePrompt = { + type: PromptKind.Rule, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: { + pathKind: FilePathKind.Relative, + path: `${name}.mdx`, + basePath: distDir, + getDirectoryName: () => baseName, + getAbsolutePath: () => distFilePath + }, + series: seriesName, + ruleName: baseName, + globs, + scope, + markdownContents: [] + } as RulePrompt + + if (yamlFrontMatter != null) Object.assign(rulePrompt, {yamlFrontMatter}) + if (parsed.rawFrontMatter != null) Object.assign(rulePrompt, {rawFrontMatter: parsed.rawFrontMatter}) + if (seriName != null) Object.assign(rulePrompt, {seriName}) + + legacyRules.push(rulePrompt) + + const localizedPrompt: LocalizedRulePrompt = { + name, + type: PromptKind.Rule, + src: { + zh: { + content, + lastModified: fs.statSync(distFilePath).mtime, + prompt: rulePrompt, + filePath: distFilePath + }, + default: { + content, + lastModified: fs.statSync(distFilePath).mtime, + prompt: rulePrompt, + filePath: distFilePath + }, + defaultLocale: 'zh' + }, + dist: { + content, + lastModified: fs.statSync(distFilePath).mtime, + prompt: rulePrompt, + filePath: distFilePath + }, + metadata: { + hasDist: true, + hasMultipleLocales: false, + isDirectoryStructure: true + }, + paths: { + dist: distFilePath + } + } + + localizedRules.push(localizedPrompt) + } catch (error) { + logger.warn('Failed to process rule from dist', {path: distFilePath, error}) } } - } catch (e) { - logger.error(`Failed to scan subdirectory at ${subDirPath}`, {error: e}) + } catch (error) { + logger.warn('Failed to scan series directory', {path: seriesDir, error}) } } + } catch (error) { + logger.warn('Failed to scan dist directory', {path: distDir, error}) } - } catch (e) { - logger.error(`Failed to scan directory at ${targetDir}`, {error: e}) } - return this.createResult(items) - } + for (const error of errors) logger.warn('Failed to read rule from src', {path: error.path, phase: error.phase, error: error.error}) - 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 promptIndex = new Map() + for (const rule of localizedRules) promptIndex.set(rule.name, rule) - 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 + return { + prompts: { + skills: [], + commands: [], + subAgents: [], + rules: localizedRules, + readme: [] + }, + promptIndex, + rules: [...localizedRulesFromSrc.map(r => r.src.default.prompt!).filter(Boolean), ...legacyRules] } } } diff --git a/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts b/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts new file mode 100644 index 00000000..394635d0 --- /dev/null +++ b/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts @@ -0,0 +1,393 @@ +import type {MdxGlobalScope} from '@truenine/md-compiler/globals' +import type { + DirectoryReadResult, + Locale, + LocalizedContent, + LocalizedPrompt, + LocalizedReadOptions, + Prompt, + PromptKind, + ReadError +} from '@truenine/plugin-shared' +import {mdxToMd} from '@truenine/md-compiler' +import {parseMarkdown} from '@truenine/md-compiler/markdown' // Re-export types for convenience + +/** + * Universal reader for localized prompts + * Handles reading src (multiple locales) and dist (compiled) content + * Supports directory structures (skills) and flat files (commands, subAgents) + */ +export class LocalizedPromptReader { + constructor( + private fs: typeof import('node:fs'), + private path: typeof import('node:path'), + private logger: import('@truenine/plugin-shared').ILogger, + private globalScope?: MdxGlobalScope + ) {} + + async readDirectoryStructure< + T extends Prompt, + K extends PromptKind + >( + srcDir: string, + distDir: string, + options: LocalizedReadOptions + ): Promise> { + const prompts: LocalizedPrompt[] = [] + const errors: ReadError[] = [] + + if (!this.exists(srcDir)) return {prompts, errors} + + try { + const entries = this.fs.readdirSync(srcDir, {withFileTypes: true}) + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + const {name} = entry + const srcEntryDir = this.path.join(srcDir, name) + const distEntryDir = this.path.join(distDir, name) + + try { + const localized = await this.readEntry( + name, + srcEntryDir, + distEntryDir, + options, + true + ) + + if (localized) prompts.push(localized) + } catch (error) { + errors.push({ + path: srcEntryDir, + error: error as Error, + phase: 'read' + }) + this.logger.error(`Failed to read entry: ${name}`, {error}) + } + } + } catch (error) { + errors.push({ + path: srcDir, + error: error as Error, + phase: 'scan' + }) + this.logger.error(`Failed to scan directory: ${srcDir}`, {error}) + } + + return {prompts, errors} + } + + async readFlatFiles< + T extends Prompt, + K extends PromptKind + >( + srcDir: string, + distDir: string, + options: LocalizedReadOptions + ): Promise> { + const prompts: LocalizedPrompt[] = [] + const errors: ReadError[] = [] + + if (!this.exists(srcDir)) return {prompts, errors} + + const zhExtension = options.localeExtensions.zh // Find all .cn.mdx files (Chinese source files) + + try { + const entries = this.fs.readdirSync(srcDir, {withFileTypes: true}) + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(zhExtension)) continue + + const baseName = entry.name.slice(0, -zhExtension.length) // Extract name without extension (e.g., "compile.cn.mdx" -> "compile") + const srcFilePath = this.path.join(srcDir, entry.name) + + try { + const localized = await this.readFlatEntry( + baseName, + srcDir, + distDir, + baseName, + options + ) + + if (localized) prompts.push(localized) + } catch (error) { + errors.push({ + path: srcFilePath, + error: error as Error, + phase: 'read' + }) + this.logger.error(`Failed to read file: ${entry.name}`, {error}) + } + } + } catch (error) { + errors.push({ + path: srcDir, + error: error as Error, + phase: 'scan' + }) + this.logger.error(`Failed to scan directory: ${srcDir}`, {error}) + } + + return {prompts, errors} + } + + async readSingleFile< + T extends Prompt, + K extends PromptKind + >( + srcBasePath: string, // Path without extension + distBasePath: string, + options: LocalizedReadOptions + ): Promise | null> { + const name = this.path.basename(srcBasePath) + + return this.readFlatEntry(name, this.path.dirname(srcBasePath), this.path.dirname(distBasePath), srcBasePath, options, true) + } + + private async readEntry< + T extends Prompt, + K extends PromptKind + >( + name: string, + srcEntryDir: string, + distEntryDir: string, + options: LocalizedReadOptions, + isDirectoryStructure = true + ): Promise | null> { + const {localeExtensions, entryFileName, createPrompt, kind} = options + + const baseFileName = entryFileName ?? name // For flat: read src/{name}.cn.mdx and src/{name}.mdx // For skills: read src/{name}/skill.cn.mdx and src/{name}/skill.mdx + const srcZhPath = this.path.join(srcEntryDir, `${baseFileName}${localeExtensions.zh}`) + const srcEnPath = this.path.join(srcEntryDir, `${baseFileName}${localeExtensions.en}`) + const distPath = this.path.join(distEntryDir, `${baseFileName}.mdx`) + + const zhContent = await this.readLocaleContent(srcZhPath, 'zh', createPrompt, name) // Read Chinese source (required) + if (!zhContent) { + this.logger.warn(`Missing required Chinese source: ${srcZhPath}`) + return null + } + + const enContent = await this.readLocaleContent(srcEnPath, 'en', createPrompt, name) // Read English source (optional) + + const distContent = await this.readDistContent(distPath, createPrompt, name) // Read dist content (optional, may not exist yet) + + const src: LocalizedPrompt['src'] = { + zh: zhContent, + ...enContent && {en: enContent}, + default: zhContent, + defaultLocale: 'zh' + } + + const hasMultipleLocales = !!enContent + const hasDist = !!distContent + + let children: string[] | undefined // Determine children (for directory structures) + if (isDirectoryStructure) children = this.scanChildren(srcEntryDir, baseFileName, localeExtensions.zh) + + return { + name, + type: kind, + src, + ...distContent && {dist: distContent}, + metadata: { + hasDist, + hasMultipleLocales, + isDirectoryStructure, + ...children && children.length > 0 && {children} + }, + paths: { + zh: srcZhPath, + ...this.exists(srcEnPath) && {en: srcEnPath}, + ...distContent && {dist: distPath} + } + } + } + + private async readFlatEntry< + T extends Prompt, + K extends PromptKind + >( + name: string, + srcDir: string, + distDir: string, + baseName: string, + options: LocalizedReadOptions, + isSingleFile = false + ): Promise | null> { + const {localeExtensions, createPrompt, kind} = options + + const srcZhPath = `${baseName}${localeExtensions.zh}` + const srcEnPath = `${baseName}${localeExtensions.en}` + const distPath = this.path.join(distDir, `${name}.mdx`) + + const fullSrcZhPath = isSingleFile ? srcZhPath : this.path.join(srcDir, srcZhPath) + const fullSrcEnPath = isSingleFile ? srcEnPath : this.path.join(srcDir, srcEnPath) + + const zhContent = await this.readLocaleContent(fullSrcZhPath, 'zh', createPrompt, name) // Read Chinese source (required) + if (!zhContent) return null + + const enContent = await this.readLocaleContent(fullSrcEnPath, 'en', createPrompt, name) // Read English source (optional) + + const distContent = await this.readDistContent(distPath, createPrompt, name) // Read dist content (optional) + + const src: LocalizedPrompt['src'] = { + zh: zhContent, + ...enContent && {en: enContent}, + default: zhContent, + defaultLocale: 'zh' + } + + return { + name, + type: kind, + src, + ...distContent && {dist: distContent}, + metadata: { + hasDist: !!distContent, + hasMultipleLocales: !!enContent, + isDirectoryStructure: false + }, + paths: { + zh: fullSrcZhPath, + ...this.exists(fullSrcEnPath) ? {en: fullSrcEnPath} : {}, + ...distContent ? {dist: distPath} : {} + } + } + } + + private async readLocaleContent( + filePath: string, + locale: Locale, + createPrompt: (content: string, locale: Locale, name: string) => T | Promise, + name: string + ): Promise | null> { + if (!this.exists(filePath)) return null + + try { + const rawMdx = this.fs.readFileSync(filePath, 'utf8') + const stats = this.fs.statSync(filePath) + + const compileResult = await mdxToMd(rawMdx, { // Compile MDX to Markdown + globalScope: this.globalScope, + extractMetadata: true, + basePath: this.path.dirname(filePath) + }) + + const parsed = parseMarkdown(rawMdx) // Parse front matter + + const prompt = await createPrompt(compileResult.content, locale, name) // Create prompt object + + const result: LocalizedContent = { + content: compileResult.content, + lastModified: stats.mtime, + filePath + } + + if (rawMdx.length > 0) { // Add optional fields only if they exist + Object.assign(result, {rawMdx}) + } + if (parsed.yamlFrontMatter != null) Object.assign(result, {frontMatter: parsed.yamlFrontMatter}) + if (prompt != null) Object.assign(result, {prompt}) + + return result + } catch (error) { + this.logger.error(`Failed to read locale content: ${filePath}`, {error}) + throw error + } + } + + private async readDistContent( + filePath: string, + createPrompt: (content: string, locale: Locale, name: string) => T | Promise, + name: string + ): Promise | null> { + if (!this.exists(filePath)) return null + + try { + const content = this.fs.readFileSync(filePath, 'utf8') + const stats = this.fs.statSync(filePath) + + const prompt = await createPrompt(content, 'zh', name) // Create prompt from dist content (no compilation needed) + + return { + content, + lastModified: stats.mtime, + prompt, + filePath + } + } catch (error) { + this.logger.warn(`Failed to read dist content: ${filePath}`, {error}) + return null + } + } + + private scanChildren( + dir: string, + entryFileName: string, + zhExtension: string + ): string[] { + const children: string[] = [] + + if (!this.exists(dir)) return children + + const entryFullName = `${entryFileName}${zhExtension}` + + try { + const scanDir = (currentDir: string, relativePath: string): void => { + const entries = this.fs.readdirSync(currentDir, {withFileTypes: true}) + + for (const entry of entries) { + const fullPath = this.path.join(currentDir, entry.name) + const relativeFullPath = relativePath + ? this.path.join(relativePath, entry.name) + : entry.name + + if (entry.isDirectory()) scanDir(fullPath, relativeFullPath) + else if (entry.name.endsWith(zhExtension) && entry.name !== entryFullName) { + const nameWithoutExt = entry.name.slice(0, -zhExtension.length) // Child doc: relative path without extension + const relativeDir = this.path.dirname(relativeFullPath) + const childPath = relativeDir === '.' + ? nameWithoutExt + : this.path.join(relativeDir, nameWithoutExt) + children.push(childPath) + } + } + } + + scanDir(dir, '') + } catch (error) { + this.logger.warn(`Failed to scan children: ${dir}`, {error}) + } + + return children + } + + private exists(path: string): boolean { + try { + return this.fs.existsSync(path) + } catch { + return false + } + } +} + +/** + * Factory function to create a LocalizedPromptReader + */ +export function createLocalizedPromptReader( + fs: typeof import('node:fs'), + path: typeof import('node:path'), + logger: import('@truenine/plugin-shared').ILogger, + globalScope?: MdxGlobalScope +): LocalizedPromptReader { + return new LocalizedPromptReader(fs, path, logger, globalScope) +} + +export { + type DirectoryReadResult, + type LocalizedReadOptions, + type ReadError +} from '@truenine/plugin-shared' diff --git a/cli/src/plugins/plugin-input-shared/index.ts b/cli/src/plugins/plugin-input-shared/index.ts index a1805ac1..f19e87a8 100644 --- a/cli/src/plugins/plugin-input-shared/index.ts +++ b/cli/src/plugins/plugin-input-shared/index.ts @@ -13,6 +13,15 @@ export { export type { FileInputPluginOptions } from './BaseFileInputPlugin' +export { + createLocalizedPromptReader, + LocalizedPromptReader +} from './LocalizedPromptReader' +export type { + DirectoryReadResult, + LocalizedReadOptions, + ReadError +} from './LocalizedPromptReader' export { GlobalScopeCollector, ScopePriority, diff --git a/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts b/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts index 132391b8..bdc865e0 100644 --- a/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts +++ b/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts @@ -1,21 +1,19 @@ -import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' import type { CollectedInputContext, InputPluginContext, - MetadataValidationResult, + Locale, + LocalizedSubAgentPrompt, PluginOptions, ResolvedBasePaths, - SubAgentPrompt, - SubAgentYAMLFrontMatter + SubAgentPrompt } 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 { + AbstractInputPlugin, + createLocalizedPromptReader +} from '@truenine/plugin-input-shared' import { FilePathKind, - PromptKind, - validateSubAgentMetadata + PromptKind } from '@truenine/plugin-shared' export interface SubAgentSeriesInfo { @@ -23,21 +21,49 @@ export interface SubAgentSeriesInfo { readonly agentName: string } -export class SubAgentInputPlugin extends BaseDirectoryInputPlugin { +export class SubAgentInputPlugin extends AbstractInputPlugin { constructor() { - super('SubAgentInputPlugin', {configKey: 'shadowSourceProject.subAgent.dist'}) + super('SubAgentInputPlugin') } - protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + private getDistDir(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) - } + private createSubAgentPrompt( + content: string, + _locale: Locale, + name: string, + srcDir: string, + _distDir: string, + ctx: InputPluginContext + ): SubAgentPrompt { + const {path} = ctx + + const slashIndex = name.indexOf('/') + const parentDirName = slashIndex !== -1 ? name.slice(0, slashIndex) : void 0 + const fileName = slashIndex !== -1 ? name.slice(slashIndex + 1) : name - protected createResult(items: SubAgentPrompt[]): Partial { - return {subAgents: items} + const seriesInfo = this.extractSeriesInfo(fileName, parentDirName) + + const filePath = path.join(srcDir, `${name}.cn.mdx`) + const entryName = `${name}.mdx` + + return { + type: PromptKind.SubAgent, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: { + pathKind: FilePathKind.Relative, + path: entryName, + basePath: srcDir, + getDirectoryName: () => entryName.replace(/\.mdx$/, ''), + getAbsolutePath: () => filePath + }, + ...seriesInfo.series != null && {series: seriesInfo.series}, + agentName: seriesInfo.agentName + } as SubAgentPrompt } extractSeriesInfo(fileName: string, parentDirName?: string): SubAgentSeriesInfo { @@ -61,140 +87,53 @@ export class SubAgentInputPlugin extends BaseDirectoryInputPlugin> { - const {userConfigOptions: options, logger, path, fs} = ctx + const {userConfigOptions: options, logger, path, fs, globalScope} = 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}) - } - } + const srcDir = this.resolveShadowPath(options.shadowSourceProject.subAgent.src, resolvedPaths.shadowProjectDir) + const distDir = this.getDistDir(options, resolvedPaths) + + const reader = createLocalizedPromptReader(fs, path, logger, globalScope) + + const {prompts: localizedSubAgents, errors} = await reader.readFlatFiles( + srcDir, + distDir, + { + kind: PromptKind.SubAgent, + localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, + isDirectoryStructure: false, + createPrompt: async (content, locale, name) => this.createSubAgentPrompt( + content, + locale, + name, + srcDir, + distDir, + ctx + ) } - } catch (e) { - logger.error(`Failed to scan directory at ${targetDir}`, {error: e}) - } + ) - return this.createResult(items) - } + for (const error of errors) logger.warn('Failed to read subAgent', {path: error.path, phase: error.phase, error: error.error}) - 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 + const legacySubAgents: SubAgentPrompt[] = [] + for (const localized of localizedSubAgents) { + const prompt = localized.dist?.prompt ?? localized.src.default.prompt + if (prompt) legacySubAgents.push(prompt) } - } - 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 + const promptIndex = new Map() + for (const sub of localizedSubAgents) promptIndex.set(sub.name, sub) 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 + prompts: { + skills: [], + commands: [], + subAgents: localizedSubAgents, + rules: [], + readme: [] }, - ...seriesInfo.series != null && {series: seriesInfo.series}, - agentName: seriesInfo.agentName, - ...seriName != null && {seriName}, - rawMdxContent: rawContent + promptIndex, + subAgents: legacySubAgents } } } diff --git a/cli/src/plugins/plugin-shared/types/InputTypes.ts b/cli/src/plugins/plugin-shared/types/InputTypes.ts index 9f67eed8..31fd19d8 100644 --- a/cli/src/plugins/plugin-shared/types/InputTypes.ts +++ b/cli/src/plugins/plugin-shared/types/InputTypes.ts @@ -6,6 +6,7 @@ import type { RuleScope } from './Enums' import type {FileContent, Path, RelativePath} from './FileSystemTypes' +import type {LocalizedPrompt, PromptsContext} from './LocalizedTypes' import type { FastCommandYAMLFrontMatter, GlobalMemoryPrompt, @@ -51,19 +52,31 @@ export interface AIAgentIgnoreConfigFile { */ export interface CollectedInputContext { readonly workspace: Workspace - readonly vscodeConfigFiles?: readonly ProjectIDEConfigFile[] - readonly jetbrainsConfigFiles?: readonly ProjectIDEConfigFile[] - readonly editorConfigFiles?: readonly ProjectIDEConfigFile[] + readonly prompts?: PromptsContext // New unified prompts container with localization support + readonly promptIndex?: Map // Quick lookup index for all localized prompts + + /** Legacy fields (deprecated, kept for backward compatibility) */ + /** @deprecated Use prompts.skills instead */ + readonly skills?: readonly SkillPrompt[] + /** @deprecated Use prompts.commands instead */ readonly fastCommands?: readonly FastCommandPrompt[] + /** @deprecated Use prompts.subAgents instead */ readonly subAgents?: readonly SubAgentPrompt[] - readonly skills?: readonly SkillPrompt[] + /** @deprecated Use prompts.rules instead */ readonly rules?: readonly RulePrompt[] + /** @deprecated Use prompts.readme instead */ + readonly readmePrompts?: readonly ReadmePrompt[] + /** @deprecated Use prompts.globalMemory instead */ readonly globalMemory?: GlobalMemoryPrompt + + /** Other non-prompt fields */ + readonly vscodeConfigFiles?: readonly ProjectIDEConfigFile[] + readonly jetbrainsConfigFiles?: readonly ProjectIDEConfigFile[] + readonly editorConfigFiles?: readonly ProjectIDEConfigFile[] readonly aiAgentIgnoreConfigFiles?: readonly AIAgentIgnoreConfigFile[] readonly globalGitIgnore?: string readonly shadowGitExclude?: string readonly shadowSourceProjectDir?: string - readonly readmePrompts?: readonly ReadmePrompt[] } /** diff --git a/cli/src/plugins/plugin-shared/types/LocalizedTypes.ts b/cli/src/plugins/plugin-shared/types/LocalizedTypes.ts new file mode 100644 index 00000000..bdf809dd --- /dev/null +++ b/cli/src/plugins/plugin-shared/types/LocalizedTypes.ts @@ -0,0 +1,224 @@ +import type {PromptKind} from './Enums' +import type {Prompt} from './PromptTypes' + +/** + * Supported locale codes + */ +export type Locale = 'zh' | 'en' + +/** + * Localized content wrapper for a single locale + * Contains both compiled content and raw MDX source + */ +export interface LocalizedContent { + /** Compiled/processed content */ + readonly content: string + + /** Original MDX source (before compilation) */ + readonly rawMdx?: string + + /** Extracted front matter */ + readonly frontMatter?: Record + + /** File last modified timestamp */ + readonly lastModified: Date + + /** Full prompt object (optional, for extended access) */ + readonly prompt?: T + + /** Absolute file path */ + readonly filePath: string +} + +/** + * Source content container for all locales + */ +export interface LocalizedSource { + /** Chinese content (.cn.mdx) */ + readonly zh?: LocalizedContent + + /** English content (.mdx) */ + readonly en?: LocalizedContent + + /** Default locale content (typically zh) */ + readonly default: LocalizedContent + + /** Which locale is the default */ + readonly defaultLocale: Locale +} + +/** Universal localized prompt wrapper */ +export interface LocalizedPrompt { + readonly name: string // Prompt identifier name + readonly type: K // Prompt type kind + readonly src: LocalizedSource // Source files content (src directory) + readonly dist?: LocalizedContent // Compiled/dist content (dist directory, optional) + + /** Metadata flags */ + readonly metadata: { + readonly hasDist: boolean // Whether dist content exists + readonly hasMultipleLocales: boolean // Whether multiple locales exist in src + readonly isDirectoryStructure: boolean // Whether this is a directory-based prompt (like skills) + + /** Available child items (for directory structures) */ + readonly children?: string[] + } + + /** File paths for all variants */ + readonly paths: { + readonly zh?: string + readonly en?: string + readonly dist?: string + } +} + +/** + * Type aliases for specific prompt types + */ +export type LocalizedSkillPrompt = LocalizedPrompt< + import('./InputTypes').SkillPrompt, + PromptKind.Skill +> + +export type LocalizedFastCommandPrompt = LocalizedPrompt< + import('./InputTypes').FastCommandPrompt, + PromptKind.FastCommand +> + +export type LocalizedSubAgentPrompt = LocalizedPrompt< + import('./InputTypes').SubAgentPrompt, + PromptKind.SubAgent +> + +export type LocalizedRulePrompt = LocalizedPrompt< + import('./InputTypes').RulePrompt, + PromptKind.Rule +> + +export type LocalizedReadmePrompt = LocalizedPrompt< + import('./InputTypes').ReadmePrompt, + PromptKind.Readme +> + +/** + * Unified prompts container for CollectedInputContext + * Replaces individual prompt arrays with localized versions + */ +export interface PromptsContext { + /** Skill prompts with localization */ + readonly skills: LocalizedSkillPrompt[] + + /** Fast command prompts with localization */ + readonly commands: LocalizedFastCommandPrompt[] + + /** Sub-agent prompts with localization */ + readonly subAgents: LocalizedSubAgentPrompt[] + + /** Rule prompts with localization */ + readonly rules: LocalizedRulePrompt[] + + /** Readme prompts with localization */ + readonly readme: LocalizedReadmePrompt[] + + /** Global memory prompt with localization */ + readonly globalMemory?: LocalizedPrompt + + /** Workspace memory prompt with localization */ + readonly workspaceMemory?: LocalizedPrompt +} + +/** + * Factory function type for creating localized prompts + */ +export type LocalizedPromptFactory = ( + name: string, + src: LocalizedSource, + dist?: LocalizedContent, + metadata?: Partial['metadata']> +) => LocalizedPrompt + +/** + * Options for reading localized prompts from different structures + */ +export interface LocalizedReadOptions { + /** File extensions for each locale */ + readonly localeExtensions: { + readonly zh: string + readonly en: string + } + + /** Entry file name (without extension, e.g., 'skill' for skills) */ + readonly entryFileName?: string + + /** Create prompt from content */ + readonly createPrompt: (content: string, locale: Locale, name: string) => T | Promise + + /** Prompt kind */ + readonly kind: K + + /** Whether this is a directory-based structure */ + readonly isDirectoryStructure: boolean +} + +/** + * Result of reading a directory structure (like skills) + */ +export interface DirectoryReadResult { + readonly prompts: LocalizedPrompt[] + readonly errors: ReadError[] +} + +/** + * Error during reading + */ +export interface ReadError { + readonly path: string + readonly error: Error + readonly phase: 'scan' | 'read' | 'compile' +} + +/** + * Locale selector for output plugins + */ +export interface LocaleSelector { + /** Select which locale to use for output */ + select: (localized: LocalizedPrompt) => LocalizedContent + + /** Check if a locale is available */ + isAvailable: (localized: LocalizedPrompt, locale: Locale) => boolean +} + +/** + * Configuration for localization behavior + */ +export interface LocalizationConfig { + /** Default locale for input reading */ + readonly defaultInputLocale: Locale + + /** Preferred locale for output (can be 'dist' to use compiled content) */ + readonly preferredOutputLocale: Locale | 'dist' + + /** Fallback behavior when preferred locale is not available */ + readonly fallbackBehavior: 'use-default' | 'skip' | 'throw' + + /** Whether to compile MDX on-the-fly if dist is missing */ + readonly autoCompile: boolean +} + +/** Default localization configuration */ +export const DEFAULT_LOCALIZATION_CONFIG: LocalizationConfig = { + defaultInputLocale: 'zh', + preferredOutputLocale: 'dist', + fallbackBehavior: 'use-default', + autoCompile: true +} + +/** + * Helper type to extract the prompt type from a LocalizedPrompt + */ +export type ExtractPromptType = T extends LocalizedPrompt ? P : never + +/** + * Helper type to extract the kind from a LocalizedPrompt + */ +export type ExtractPromptKind = T extends LocalizedPrompt ? K : never diff --git a/cli/src/plugins/plugin-shared/types/index.ts b/cli/src/plugins/plugin-shared/types/index.ts index 7d516e26..5e07d0c5 100644 --- a/cli/src/plugins/plugin-shared/types/index.ts +++ b/cli/src/plugins/plugin-shared/types/index.ts @@ -4,6 +4,7 @@ export * from './Errors' export * from './ExportMetadataTypes' export * from './FileSystemTypes' export * from './InputTypes' +export * from './LocalizedTypes' export * from './OutputTypes' export * from './PluginTypes' export * from './PromptTypes' From c2f41f085f4e09e923038bb6ca323763a05efe07 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 09:08:31 +0800 Subject: [PATCH 12/30] =?UTF-8?q?refactor(plugin-input-orphan-cleanup-effe?= =?UTF-8?q?ct):=20=E7=AE=80=E5=8C=96=E6=8A=80=E8=83=BD=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=8C=B9=E9=85=8D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除对skill/SKILL文件的特殊处理,统一使用SKILL.cn.mdx作为主技能文件路径 --- .../OrphanFileCleanupEffectInputPlugin.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts b/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts index 7121b029..69f9cf9b 100644 --- a/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts +++ b/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts @@ -153,19 +153,16 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { relativeDir: string ): string[] { switch (dirType) { - case 'skills': { // Skill structure: dist/skills/{name}/{file}.mdx -> src/skills/{name}/{file}.cn.mdx + case 'skills': { // Skill structure: dist/skills/{name}.mdx -> src/skills/{name}/SKILL.cn.mdx const skillParts = relativeDir === '.' ? [baseName] : relativeDir.split(nodePath.sep) const skillName = skillParts[0] ?? baseName const remainingPath = relativeDir === '.' ? '' : relativeDir.slice(skillName.length + 1) // +1 for separator if (remainingPath !== '') return [nodePath.join(shadowProjectDir, srcPath, skillName, remainingPath, `${baseName}.cn.mdx`)] // Nested child doc - if (baseName === 'skill' || baseName === 'SKILL') { // Main skill file - return [ - nodePath.join(shadowProjectDir, srcPath, skillName, 'skill.cn.mdx'), - nodePath.join(shadowProjectDir, srcPath, skillName, 'SKILL.cn.mdx') - ] - } - return [nodePath.join(shadowProjectDir, srcPath, skillName, `${baseName}.cn.mdx`)] // Direct child doc in skill root + return [ // Main skill file: dist/skills/{skillName}.mdx -> src/skills/{skillName}/SKILL.cn.mdx + nodePath.join(shadowProjectDir, srcPath, skillName, 'SKILL.cn.mdx'), + nodePath.join(shadowProjectDir, srcPath, skillName, 'skill.cn.mdx') + ] } case 'commands': return relativeDir === '.' From 5dba3626fec6d18641fdabb15000679fd0aa824c Mon Sep 17 00:00:00 2001 From: TrueNine Date: Mon, 2 Mar 2026 10:12:31 +0800 Subject: [PATCH 13/30] =?UTF-8?q?refactor(plugins):=20=E5=B0=86=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E8=BE=93=E5=85=A5=E6=A8=A1=E5=9D=97=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=B9=B6=E7=A7=BB=E5=8A=A8=E5=88=B0inputs=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构所有输入插件,从plugins目录迁移到新的inputs目录,并统一代码结构。删除旧的插件模块及相关测试文件,更新tsconfig和插件配置引用路径。新增inputs目录下的index.ts作为统一导出入口。 - 删除原plugin-input-*系列模块 - 创建新的inputs目录结构 - 统一输入插件的实现方式 - 更新类型定义和导出方式 - 修改项目配置和引用路径 --- .../effect-md-cleanup.ts} | 5 - .../effect-orphan-cleanup.ts} | 41 +- .../effect-skill-sync.ts} | 3 - cli/src/inputs/index.ts | 55 ++ cli/src/inputs/input-agentskills-types.ts | 10 + cli/src/inputs/input-agentskills.ts | 605 ++++++++++++++++++ .../input-editorconfig.ts} | 0 .../input-fast-command.ts} | 0 cli/src/inputs/input-git-exclude.ts | 29 + cli/src/inputs/input-gitignore.ts | 29 + .../input-global-memory.ts} | 2 +- .../input-jetbrains-config.ts} | 0 .../input-project-prompt.ts} | 7 - .../input-readme.ts} | 12 - .../input-rule.ts} | 14 +- .../input-shadow-project.ts} | 4 +- .../input-shared-ignore.ts} | 5 - .../input-subagent.ts} | 0 .../input-vscode-config.ts} | 0 .../input-workspace.ts} | 4 +- cli/src/plugin.config.ts | 38 +- .../ResourceProcessor.ts | 176 ----- .../SkillInputPlugin.test.ts | 309 --------- .../SkillInputPlugin.ts | 349 ---------- .../config/fileTypes.ts | 199 ------ .../plugins/plugin-input-agentskills/index.ts | 3 - .../plugin-input-editorconfig/index.ts | 3 - .../FastCommandInputPlugin.test.ts | 131 ---- .../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 | 64 -- .../GitIgnoreInputPlugin.ts | 20 - .../plugins/plugin-input-gitignore/index.ts | 3 - .../plugin-input-global-memory/index.ts | 3 - .../plugin-input-jetbrains-config/index.ts | 3 - ...eCleanupEffectInputPlugin.property.test.ts | 311 --------- .../plugin-input-md-cleanup-effect/index.ts | 6 - ...eCleanupEffectInputPlugin.property.test.ts | 263 -------- .../index.ts | 6 - .../ProjectPromptInputPlugin.test.ts | 214 ------- .../plugin-input-project-prompt/index.ts | 3 - .../ReadmeMdInputPlugin.property.test.ts | 365 ----------- cli/src/plugins/plugin-input-readme/index.ts | 3 - .../plugin-input-rule/RuleInputPlugin.test.ts | 322 ---------- cli/src/plugins/plugin-input-rule/index.ts | 3 - .../ShadowProjectInputPlugin.test.ts | 164 ----- .../plugin-input-shadow-project/index.ts | 3 - .../plugin-input-shared-ignore/index.ts | 3 - ...FileSyncEffectInputPlugin.property.test.ts | 261 -------- .../plugin-input-skill-sync-effect/index.ts | 6 - .../SubAgentInputPlugin.test.ts | 137 ---- .../plugins/plugin-input-subagent/index.ts | 6 - .../plugin-input-vscode-config/index.ts | 3 - .../plugins/plugin-input-workspace/index.ts | 3 - cli/tsconfig.json | 18 - 57 files changed, 769 insertions(+), 3567 deletions(-) rename cli/src/{plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts => inputs/effect-md-cleanup.ts} (98%) rename cli/src/{plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts => inputs/effect-orphan-cleanup.ts} (86%) rename cli/src/{plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.ts => inputs/effect-skill-sync.ts} (99%) create mode 100644 cli/src/inputs/index.ts create mode 100644 cli/src/inputs/input-agentskills-types.ts create mode 100644 cli/src/inputs/input-agentskills.ts rename cli/src/{plugins/plugin-input-editorconfig/EditorConfigInputPlugin.ts => inputs/input-editorconfig.ts} (100%) rename cli/src/{plugins/plugin-input-fast-command/FastCommandInputPlugin.ts => inputs/input-fast-command.ts} (100%) create mode 100644 cli/src/inputs/input-git-exclude.ts create mode 100644 cli/src/inputs/input-gitignore.ts rename cli/src/{plugins/plugin-input-global-memory/GlobalMemoryInputPlugin.ts => inputs/input-global-memory.ts} (94%) rename cli/src/{plugins/plugin-input-jetbrains-config/JetBrainsConfigInputPlugin.ts => inputs/input-jetbrains-config.ts} (100%) rename cli/src/{plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts => inputs/input-project-prompt.ts} (98%) rename cli/src/{plugins/plugin-input-readme/ReadmeMdInputPlugin.ts => inputs/input-readme.ts} (91%) rename cli/src/{plugins/plugin-input-rule/RuleInputPlugin.ts => inputs/input-rule.ts} (93%) rename cli/src/{plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.ts => inputs/input-shadow-project.ts} (98%) rename cli/src/{plugins/plugin-input-shared-ignore/AIAgentIgnoreInputPlugin.ts => inputs/input-shared-ignore.ts} (87%) rename cli/src/{plugins/plugin-input-subagent/SubAgentInputPlugin.ts => inputs/input-subagent.ts} (100%) rename cli/src/{plugins/plugin-input-vscode-config/VSCodeConfigInputPlugin.ts => inputs/input-vscode-config.ts} (100%) rename cli/src/{plugins/plugin-input-workspace/WorkspaceInputPlugin.ts => inputs/input-workspace.ts} (93%) delete mode 100644 cli/src/plugins/plugin-input-agentskills/ResourceProcessor.ts delete mode 100644 cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts delete mode 100644 cli/src/plugins/plugin-input-agentskills/config/fileTypes.ts delete mode 100644 cli/src/plugins/plugin-input-agentskills/index.ts delete mode 100644 cli/src/plugins/plugin-input-editorconfig/index.ts delete mode 100644 cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-input-fast-command/index.ts delete mode 100644 cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-input-git-exclude/GitExcludeInputPlugin.ts delete mode 100644 cli/src/plugins/plugin-input-git-exclude/index.ts delete mode 100644 cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts delete mode 100644 cli/src/plugins/plugin-input-gitignore/index.ts delete mode 100644 cli/src/plugins/plugin-input-global-memory/index.ts delete mode 100644 cli/src/plugins/plugin-input-jetbrains-config/index.ts delete mode 100644 cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.property.test.ts delete mode 100644 cli/src/plugins/plugin-input-md-cleanup-effect/index.ts delete mode 100644 cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.property.test.ts delete mode 100644 cli/src/plugins/plugin-input-orphan-cleanup-effect/index.ts delete mode 100644 cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-input-project-prompt/index.ts delete mode 100644 cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.property.test.ts delete mode 100644 cli/src/plugins/plugin-input-readme/index.ts delete mode 100644 cli/src/plugins/plugin-input-rule/RuleInputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-input-rule/index.ts delete mode 100644 cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-input-shadow-project/index.ts delete mode 100644 cli/src/plugins/plugin-input-shared-ignore/index.ts delete mode 100644 cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.property.test.ts delete mode 100644 cli/src/plugins/plugin-input-skill-sync-effect/index.ts delete mode 100644 cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-input-subagent/index.ts delete mode 100644 cli/src/plugins/plugin-input-vscode-config/index.ts delete mode 100644 cli/src/plugins/plugin-input-workspace/index.ts diff --git a/cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts b/cli/src/inputs/effect-md-cleanup.ts similarity index 98% rename from cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts rename to cli/src/inputs/effect-md-cleanup.ts index e2d40cd3..73a8bd0a 100644 --- a/cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts +++ b/cli/src/inputs/effect-md-cleanup.ts @@ -6,9 +6,6 @@ import type { } 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[] @@ -121,9 +118,7 @@ export class MarkdownWhitespaceCleanupEffectInputPlugin extends AbstractInputPlu 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[] = [] diff --git a/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts b/cli/src/inputs/effect-orphan-cleanup.ts similarity index 86% rename from cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts rename to cli/src/inputs/effect-orphan-cleanup.ts index 69f9cf9b..b9a9566c 100644 --- a/cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts +++ b/cli/src/inputs/effect-orphan-cleanup.ts @@ -1,9 +1,6 @@ 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[] @@ -34,7 +31,7 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { } } - const shadowConfig = userConfigOptions.shadowSourceProject // Get source paths from config, fallback to defaults + const shadowConfig = userConfigOptions.shadowSourceProject const srcPaths: Record = { skills: shadowConfig?.skill?.src ?? 'src/skills', commands: shadowConfig?.fastCommand?.src ?? 'src/commands', @@ -90,7 +87,6 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { if (entry.isDirectory()) { this.cleanupDirectory(ctx, entryPath, dirType, srcPath, deletedFiles, deletedDirs, errors, dryRun) - this.removeEmptyDirectory(ctx, entryPath, deletedDirs, errors, dryRun) } else if (entry.isFile()) { const isOrphan = this.isOrphanFile(ctx, entryPath, dirType, srcPath, shadowProjectDir) @@ -134,13 +130,10 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { if (isMdxFile) { const possibleSrcPaths = this.getPossibleSourcePaths(path, shadowProjectDir, dirType, srcPath, baseName, relativeDir) - return !possibleSrcPaths.some(srcPath => fs.existsSync(srcPath)) } const possibleSrcPaths: string[] = [] - possibleSrcPaths.push(path.join(shadowProjectDir, srcPath, relativeFromType)) - return !possibleSrcPaths.some(srcPath => fs.existsSync(srcPath)) } @@ -153,41 +146,29 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { relativeDir: string ): string[] { switch (dirType) { - case 'skills': { // Skill structure: dist/skills/{name}.mdx -> src/skills/{name}/SKILL.cn.mdx + case 'skills': { const skillParts = relativeDir === '.' ? [baseName] : relativeDir.split(nodePath.sep) const skillName = skillParts[0] ?? baseName - const remainingPath = relativeDir === '.' ? '' : relativeDir.slice(skillName.length + 1) // +1 for separator + const remainingPath = relativeDir === '.' ? '' : relativeDir.slice(skillName.length + 1) - if (remainingPath !== '') return [nodePath.join(shadowProjectDir, srcPath, skillName, remainingPath, `${baseName}.cn.mdx`)] // Nested child doc - return [ // Main skill file: dist/skills/{skillName}.mdx -> src/skills/{skillName}/SKILL.cn.mdx + if (remainingPath !== '') return [nodePath.join(shadowProjectDir, srcPath, skillName, remainingPath, `${baseName}.cn.mdx`)] + return [ nodePath.join(shadowProjectDir, srcPath, skillName, 'SKILL.cn.mdx'), nodePath.join(shadowProjectDir, srcPath, skillName, 'skill.cn.mdx') ] } case 'commands': return relativeDir === '.' - ? [ - nodePath.join(shadowProjectDir, srcPath, `${baseName}.cn.mdx`) - ] - : [ - nodePath.join(shadowProjectDir, srcPath, relativeDir, `${baseName}.cn.mdx`) - ] + ? [nodePath.join(shadowProjectDir, srcPath, `${baseName}.cn.mdx`)] + : [nodePath.join(shadowProjectDir, srcPath, relativeDir, `${baseName}.cn.mdx`)] case 'agents': return relativeDir === '.' - ? [ - nodePath.join(shadowProjectDir, srcPath, `${baseName}.cn.mdx`) - ] - : [ - nodePath.join(shadowProjectDir, srcPath, relativeDir, `${baseName}.cn.mdx`) - ] + ? [nodePath.join(shadowProjectDir, srcPath, `${baseName}.cn.mdx`)] + : [nodePath.join(shadowProjectDir, srcPath, relativeDir, `${baseName}.cn.mdx`)] case 'app': return relativeDir === '.' - ? [ - nodePath.join(shadowProjectDir, srcPath, `${baseName}.cn.mdx`) - ] - : [ - nodePath.join(shadowProjectDir, srcPath, relativeDir, `${baseName}.cn.mdx`) - ] + ? [nodePath.join(shadowProjectDir, srcPath, `${baseName}.cn.mdx`)] + : [nodePath.join(shadowProjectDir, srcPath, relativeDir, `${baseName}.cn.mdx`)] default: return [] } } diff --git a/cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.ts b/cli/src/inputs/effect-skill-sync.ts similarity index 99% rename from cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.ts rename to cli/src/inputs/effect-skill-sync.ts index c78a5a12..333f9260 100644 --- a/cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.ts +++ b/cli/src/inputs/effect-skill-sync.ts @@ -4,9 +4,6 @@ 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[] diff --git a/cli/src/inputs/index.ts b/cli/src/inputs/index.ts new file mode 100644 index 00000000..7468df48 --- /dev/null +++ b/cli/src/inputs/index.ts @@ -0,0 +1,55 @@ +export { + MarkdownWhitespaceCleanupEffectInputPlugin +} from './effect-md-cleanup' +export { + OrphanFileCleanupEffectInputPlugin +} from './effect-orphan-cleanup' +export { + SkillNonSrcFileSyncEffectInputPlugin +} from './effect-skill-sync' // Effect Input Plugins (按优先级排序: 10, 20, 30) + +export { + SkillInputPlugin +} from './input-agentskills' +export { + EditorConfigInputPlugin +} from './input-editorconfig' +export { + FastCommandInputPlugin +} from './input-fast-command' +export { + GitExcludeInputPlugin +} from './input-git-exclude' +export { + GitIgnoreInputPlugin +} from './input-gitignore' +export { + GlobalMemoryInputPlugin +} from './input-global-memory' +export { + JetBrainsConfigInputPlugin +} from './input-jetbrains-config' +export { + ProjectPromptInputPlugin +} from './input-project-prompt' +export { + ReadmeMdInputPlugin +} from './input-readme' +export { + RuleInputPlugin +} from './input-rule' +export { + ShadowProjectInputPlugin +} from './input-shadow-project' +export { + AIAgentIgnoreInputPlugin +} from './input-shared-ignore' +export { + SubAgentInputPlugin +} from './input-subagent' +export { + VSCodeConfigInputPlugin +} from './input-vscode-config' +export { + WorkspaceInputPlugin +} from './input-workspace' // Regular Input Plugins diff --git a/cli/src/inputs/input-agentskills-types.ts b/cli/src/inputs/input-agentskills-types.ts new file mode 100644 index 00000000..b422734d --- /dev/null +++ b/cli/src/inputs/input-agentskills-types.ts @@ -0,0 +1,10 @@ +/** + * Types for SkillInputPlugin resource processing + */ + +import type {SkillChildDoc, SkillResource} from '@truenine/plugin-shared' + +export interface ResourceScanResult { + readonly childDocs: SkillChildDoc[] + readonly resources: SkillResource[] +} diff --git a/cli/src/inputs/input-agentskills.ts b/cli/src/inputs/input-agentskills.ts new file mode 100644 index 00000000..ce484495 --- /dev/null +++ b/cli/src/inputs/input-agentskills.ts @@ -0,0 +1,605 @@ +import type { + CollectedInputContext, + ILogger, + InputPluginContext, + LocalizedPrompt, + LocalizedSkillPrompt, + McpServerConfig, + SkillChildDoc, + SkillMcpConfig, + SkillPrompt, + SkillResource, + SkillResourceEncoding, + SkillYAMLFrontMatter +} from '@truenine/plugin-shared' +import type {Dirent} from 'node:fs' +import type {ResourceScanResult} from './input-agentskills-types' + +import {Buffer} from 'node:buffer' +import * as nodePath 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, createLocalizedPromptReader} from '@truenine/plugin-input-shared' +import {FilePathKind, PromptKind, validateSkillMetadata} from '@truenine/plugin-shared' + +export * from './input-agentskills-types' // Re-export from types file + +const MIME_TYPES: Record = { // MIME types for resources + '.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' +} + +const SKILL_RESOURCE_BINARY_EXTENSIONS = new Set([ // Binary extensions + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.ico', + '.bmp', + '.tiff', + '.svg', + '.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', + '.pdf', + '.docx', + '.doc', + '.xlsx', + '.xls', + '.pptx', + '.ppt', + '.odt', + '.ods', + '.odp' +]) + +type ResourceCategory = 'image' | 'code' | 'data' | 'document' | 'config' | 'script' | 'binary' | 'other' + +const FILE_TYPE_CATEGORIES: Record = { + image: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.tiff', '.svg'], + code: ['.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'], + data: ['.sql', '.json', '.jsonc', '.json5', '.xml', '.xsd', '.xsl', '.xslt', '.yaml', '.yml', '.toml', '.csv', '.tsv', '.graphql', '.gql', '.proto'], + document: ['.txt', '.text', '.rtf', '.log', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt', '.pdf', '.odt', '.ods', '.odp'], + config: ['.ini', '.conf', '.cfg', '.config', '.properties', '.env', '.envrc', '.editorconfig', '.gitignore', '.gitattributes', '.npmrc', '.nvmrc', '.npmignore', '.eslintrc', '.prettierrc', '.stylelintrc', '.babelrc', '.browserslistrc'], + script: ['.sh', '.bash', '.zsh', '.fish', '.ps1', '.psm1', '.psd1', '.bat', '.cmd'], + binary: ['.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'] +} + +function getResourceCategory(ext: string): ResourceCategory { + const lowerExt = ext.toLowerCase() + for (const [category, extensions] of Object.entries(FILE_TYPE_CATEGORIES)) { + if (extensions.includes(lowerExt)) return category as ResourceCategory + } + return 'other' +} + +function isBinaryResourceExtension(ext: string): boolean { + return SKILL_RESOURCE_BINARY_EXTENSIONS.has(ext.toLowerCase()) +} + +function getMimeType(ext: string): string | undefined { + return MIME_TYPES[ext.toLowerCase()] +} + +function pathJoin(...segments: string[]): string { + const joined = nodePath.join(...segments) + return joined.replaceAll('\\', '/') +} + +interface ResourceProcessorContext { + readonly fs: typeof import('node:fs') + readonly logger: ILogger + readonly skillDir: string +} + +class ResourceProcessor { + private readonly ctx: ResourceProcessorContext + + constructor(ctx: ResourceProcessorContext) { + this.ctx = ctx + } + + processDirectory(entry: Dirent, currentRelativePath: string, filePath: string): ResourceScanResult { + const relativePath = currentRelativePath + ? `${currentRelativePath}/${entry.name}` + : entry.name + return this.scanSkillDirectory(filePath, relativePath) + } + + processFile(entry: Dirent, currentRelativePath: string, filePath: string): ResourceScanResult { + const relativePath = currentRelativePath + ? `${currentRelativePath}/${entry.name}` + : entry.name + + if (currentRelativePath === '' && entry.name === 'skill.mdx') return {childDocs: [], resources: []} + + if (currentRelativePath === '' && entry.name === 'mcp.json') return {childDocs: [], resources: []} + + if (entry.name.endsWith('.mdx')) { + const childDoc = this.processChildDoc(entry.name, relativePath, filePath) + return {childDocs: childDoc ? [childDoc] : [], resources: []} + } + + const resource = this.processResourceFile(entry.name, relativePath, filePath) + return {childDocs: [], resources: resource ? [resource] : []} + } + + private processChildDoc(_fileName: string, relativePath: string, filePath: string): SkillChildDoc | null { + try { + const rawContent = this.ctx.fs.readFileSync(filePath, 'utf8') + const parsed = parseMarkdown(rawContent) + const content = transformMdxReferencesToMd(parsed.contentWithoutFrontMatter) + + return { + 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: this.ctx.skillDir, + getDirectoryName: () => nodePath.dirname(relativePath), + getAbsolutePath: () => filePath + } + } + } + catch (e) { + this.ctx.logger.warn('failed to read child doc', {path: relativePath, error: e}) + return null + } + } + + private processResourceFile(fileName: string, relativePath: string, filePath: string): SkillResource | null { + const ext = nodePath.extname(fileName) + + try { + const {content, encoding, length} = this.readFileContent(filePath, ext) + const mimeType = getMimeType(ext) + + const resource: SkillResource = { + type: PromptKind.SkillResource, + extension: ext, + fileName, + relativePath, + content, + encoding, + category: getResourceCategory(ext), + length, + ...mimeType != null && {mimeType} + } + + return resource + } + catch (e) { + this.ctx.logger.warn('failed to read resource file', {path: relativePath, error: e}) + return null + } + } + + private readFileContent(filePath: string, ext: string): {content: string, encoding: SkillResourceEncoding, length: number} { + if (isBinaryResourceExtension(ext)) { + const buffer = this.ctx.fs.readFileSync(filePath) + return { + content: buffer.toString('base64'), + encoding: 'base64', + length: buffer.length + } + } + + const content = this.ctx.fs.readFileSync(filePath, 'utf8') + return { + content, + encoding: 'text', + length: Buffer.from(content, 'utf8').length + } + } + + scanSkillDirectory(currentDir: string, currentRelativePath: string = ''): ResourceScanResult { + const childDocs: SkillChildDoc[] = [] + const resources: SkillResource[] = [] + + let entries: Dirent[] + try { + entries = this.ctx.fs.readdirSync(currentDir, {withFileTypes: true}) + } + catch (e) { + this.ctx.logger.warn('failed to scan directory', {path: currentDir, error: e}) + return {childDocs, resources} + } + + for (const entry of entries) { + const filePath = pathJoin(currentDir, entry.name) + + if (entry.isDirectory()) { + const subResult = this.processDirectory(entry, currentRelativePath, filePath) + childDocs.push(...subResult.childDocs) + resources.push(...subResult.resources) + continue + } + + if (!entry.isFile()) continue + + const fileResult = this.processFile(entry, currentRelativePath, filePath) + childDocs.push(...fileResult.childDocs) + resources.push(...fileResult.resources) + } + + return {childDocs, resources} + } +} + +function readMcpConfig( + skillDir: string, + fs: typeof import('node:fs'), + logger: ILogger +): SkillMcpConfig | undefined { + const mcpJsonPath = nodePath.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 + } +} + +async function createSkillPrompt( + content: string, + _locale: 'zh' | 'en', + name: string, + skillDir: string, + skillAbsoluteDir: string, + ctx: InputPluginContext, + mcpConfig?: SkillMcpConfig, + childDocs: SkillPrompt['childDocs'] = [], + resources: SkillPrompt['resources'] = [], + seriName?: string | string[] | null +): Promise { + const {logger, globalScope, fs} = ctx + + const srcFilePath = nodePath.join(skillAbsoluteDir, 'skill.cn.mdx') + let rawContent = content + let parsed: ReturnType> | undefined + + if (fs.existsSync(srcFilePath)) { + try { + rawContent = fs.readFileSync(srcFilePath, 'utf8') + parsed = parseMarkdown(rawContent) + + const compileResult = await mdxToMd(rawContent, { + globalScope, + extractMetadata: true, + basePath: skillAbsoluteDir + }) + + content = transformMdxReferencesToMd(compileResult.content) + } + catch (e) { + logger.warn('failed to recompile skill from source', {skill: name, error: e}) + } + } + + const mergedFrontMatter: SkillYAMLFrontMatter = { + ...parsed?.yamlFrontMatter ?? {}, + name, + description: '' + } as SkillYAMLFrontMatter + + return { + type: PromptKind.Skill, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + yamlFrontMatter: mergedFrontMatter, + markdownAst: parsed?.markdownAst, + markdownContents: parsed?.markdownContents ?? [], + dir: { + pathKind: FilePathKind.Relative, + path: name, + basePath: skillDir, + getDirectoryName: () => name, + getAbsolutePath: () => nodePath.join(skillDir, name) + }, + ...parsed?.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, + ...mcpConfig != null && {mcpConfig}, + ...childDocs != null && childDocs.length > 0 && {childDocs}, + ...resources != null && resources.length > 0 && {resources}, + ...seriName != null && {seriName} + } as SkillPrompt +} + +async function processSkillFile( + skillFilePath: string, + skillDir: string, + entryName: string, + skillAbsoluteDir: string, + ctx: InputPluginContext +): Promise { + const {logger, globalScope, fs} = ctx + + let rawContent: string + try { + rawContent = fs.readFileSync(skillFilePath, 'utf8') + } + catch (e) { + logger.error('failed to read skill file', {file: skillFilePath, error: e}) + return null + } + + let parsed: ReturnType> + try { + parsed = parseMarkdown(rawContent) + } + catch (e) { + logger.error('failed to parse skill markdown', {file: skillFilePath, error: e}) + return null + } + + let compileResult: Awaited> + try { + compileResult = await mdxToMd(rawContent, { + globalScope, + extractMetadata: true, + basePath: skillAbsoluteDir + }) + } + catch (e) { + logger.error('failed to compile skill mdx', {file: skillFilePath, error: e}) + return null + } + + 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) + + logger.debug('skill metadata extracted', { + skill: entryName, + source: compileResult.metadata.source, + hasYaml: parsed.yamlFrontMatter != null, + hasExport: Object.keys(compileResult.metadata.fields).length > 0 + }) + + const processor = new ResourceProcessor({fs, logger, skillDir: skillAbsoluteDir}) + const {childDocs, resources} = processor.scanSkillDirectory(skillAbsoluteDir) + const mcpConfig = readMcpConfig(skillAbsoluteDir, fs, logger) + + const {seriName} = mergedFrontMatter + + return { + type: PromptKind.Skill, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + yamlFrontMatter: mergedFrontMatter.name != null + ? mergedFrontMatter + : {name: entryName, 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: entryName, + basePath: skillDir, + getDirectoryName: () => entryName, + getAbsolutePath: () => nodePath.join(skillDir, entryName) + } + } +} + +export class SkillInputPlugin extends AbstractInputPlugin { + constructor() { + super('SkillInputPlugin') + } + + readMcpConfig( + skillDir: string, + fs: typeof import('node:fs'), + logger: ILogger + ): SkillMcpConfig | undefined { + return readMcpConfig(skillDir, fs, logger) + } + + scanSkillDirectory( + skillDir: string, + fs: typeof import('node:fs'), + logger: ILogger, + currentRelativePath: string = '' + ): ResourceScanResult { + const processor = new ResourceProcessor({fs, logger, skillDir}) + return processor.scanSkillDirectory(skillDir, currentRelativePath) + } + + async collect(ctx: InputPluginContext): Promise> { + const {userConfigOptions: options, logger, fs, path: pathModule, globalScope} = ctx + const {shadowProjectDir} = this.resolveBasePaths(options) + + const srcSkillDir = this.resolveShadowPath(options.shadowSourceProject.skill.src, shadowProjectDir) + const distSkillDir = this.resolveShadowPath(options.shadowSourceProject.skill.dist, shadowProjectDir) + + const legacySkills: SkillPrompt[] = [] + const reader = createLocalizedPromptReader(fs, pathModule, logger, globalScope) + + const {prompts: localizedSkills, errors} = await reader.readDirectoryStructure( + srcSkillDir, + distSkillDir, + { + kind: PromptKind.Skill, + entryFileName: 'skill', + localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, + isDirectoryStructure: true, + createPrompt: async (content, locale, name) => { + const skillSrcDir = pathModule.join(srcSkillDir, name) + const processor = new ResourceProcessor({fs, logger, skillDir: skillSrcDir}) + const {childDocs, resources} = processor.scanSkillDirectory(skillSrcDir) + const mcpConfig = readMcpConfig(skillSrcDir, fs, logger) + + return createSkillPrompt( + content, + locale, + name, + distSkillDir, + skillSrcDir, + ctx, + mcpConfig, + childDocs, + resources + ) + } + } + ) + + for (const error of errors) logger.warn('Failed to read skill', {path: error.path, phase: error.phase, error: error.error}) + + for (const localized of localizedSkills) { + const prompt = localized.dist?.prompt ?? localized.src.default.prompt + if (prompt) legacySkills.push(prompt) + } + + if (fs.existsSync(distSkillDir)) { + const distEntries = fs.readdirSync(distSkillDir, {withFileTypes: true}) + const existingNames = new Set(localizedSkills.map(s => s.name)) + + for (const entry of distEntries) { + if (!entry.isDirectory()) continue + if (existingNames.has(entry.name)) continue + + const entryName = entry.name + const skillFilePath = pathModule.join(distSkillDir, entryName, 'skill.mdx') + const skillAbsoluteDir = pathModule.join(distSkillDir, entryName) + + if (!fs.existsSync(skillFilePath)) continue + + try { + const skill = await processSkillFile( + skillFilePath, + distSkillDir, + entryName, + skillAbsoluteDir, + ctx + ) + if (skill) legacySkills.push(skill) + } + catch (e) { + logger.error('failed to parse skill', {file: skillFilePath, error: e}) + } + } + } + + const promptIndex = new Map() + for (const skill of localizedSkills) promptIndex.set(skill.name, skill) + + return { + prompts: { + skills: localizedSkills as LocalizedSkillPrompt[], + commands: [], + subAgents: [], + rules: [], + readme: [] + }, + promptIndex, + skills: legacySkills + } + } +} diff --git a/cli/src/plugins/plugin-input-editorconfig/EditorConfigInputPlugin.ts b/cli/src/inputs/input-editorconfig.ts similarity index 100% rename from cli/src/plugins/plugin-input-editorconfig/EditorConfigInputPlugin.ts rename to cli/src/inputs/input-editorconfig.ts diff --git a/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts b/cli/src/inputs/input-fast-command.ts similarity index 100% rename from cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts rename to cli/src/inputs/input-fast-command.ts diff --git a/cli/src/inputs/input-git-exclude.ts b/cli/src/inputs/input-git-exclude.ts new file mode 100644 index 00000000..05540b2c --- /dev/null +++ b/cli/src/inputs/input-git-exclude.ts @@ -0,0 +1,29 @@ +import type {CollectedInputContext, InputPluginContext} from '@truenine/plugin-shared' +import * as path from 'node:path' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' + +export class GitExcludeInputPlugin extends AbstractInputPlugin { + constructor() { + super('GitExcludeInputPlugin') + } + + collect(ctx: InputPluginContext): Partial { + const {shadowProjectDir} = this.resolveBasePaths(ctx.userConfigOptions) + const filePath = path.join(shadowProjectDir, 'public', 'exclude') + + if (!ctx.fs.existsSync(filePath)) { + this.log.debug({action: 'collect', message: 'File not found', path: filePath}) + return {} + } + + const content = ctx.fs.readFileSync(filePath, 'utf8') + + if (content.length === 0) { + 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 {shadowGitExclude: content} + } +} diff --git a/cli/src/inputs/input-gitignore.ts b/cli/src/inputs/input-gitignore.ts new file mode 100644 index 00000000..b8125baf --- /dev/null +++ b/cli/src/inputs/input-gitignore.ts @@ -0,0 +1,29 @@ +import type {CollectedInputContext, InputPluginContext} from '@truenine/plugin-shared' +import * as path from 'node:path' +import {AbstractInputPlugin} from '@truenine/plugin-input-shared' + +export class GitIgnoreInputPlugin extends AbstractInputPlugin { + constructor() { + super('GitIgnoreInputPlugin') + } + + collect(ctx: InputPluginContext): Partial { + const {shadowProjectDir} = this.resolveBasePaths(ctx.userConfigOptions) + const filePath = path.join(shadowProjectDir, 'public', 'gitignore') + + if (!ctx.fs.existsSync(filePath)) { + this.log.debug({action: 'collect', message: 'File not found', path: filePath}) + return {} + } + + const content = ctx.fs.readFileSync(filePath, 'utf8') + + if (content.length === 0) { + 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 {globalGitIgnore: content} + } +} diff --git a/cli/src/plugins/plugin-input-global-memory/GlobalMemoryInputPlugin.ts b/cli/src/inputs/input-global-memory.ts similarity index 94% rename from cli/src/plugins/plugin-input-global-memory/GlobalMemoryInputPlugin.ts rename to cli/src/inputs/input-global-memory.ts index e4873da7..3fb8c504 100644 --- a/cli/src/plugins/plugin-input-global-memory/GlobalMemoryInputPlugin.ts +++ b/cli/src/inputs/input-global-memory.ts @@ -37,7 +37,7 @@ export class GlobalMemoryInputPlugin extends AbstractInputPlugin { 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} + let compiledContent: string if (globalScope != null) { try { compiledContent = await mdxToMd(rawContent, {globalScope, basePath: path.dirname(globalMemoryFile)}) diff --git a/cli/src/plugins/plugin-input-jetbrains-config/JetBrainsConfigInputPlugin.ts b/cli/src/inputs/input-jetbrains-config.ts similarity index 100% rename from cli/src/plugins/plugin-input-jetbrains-config/JetBrainsConfigInputPlugin.ts rename to cli/src/inputs/input-jetbrains-config.ts diff --git a/cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts b/cli/src/inputs/input-project-prompt.ts similarity index 98% rename from cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts rename to cli/src/inputs/input-project-prompt.ts index 5ac29d81..c0959caf 100644 --- a/cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts +++ b/cli/src/inputs/input-project-prompt.ts @@ -17,14 +17,7 @@ import { 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 { diff --git a/cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.ts b/cli/src/inputs/input-readme.ts similarity index 91% rename from cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.ts rename to cli/src/inputs/input-readme.ts index 28460926..46ea6da1 100644 --- a/cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.ts +++ b/cli/src/inputs/input-readme.ts @@ -9,18 +9,6 @@ import {FilePathKind, PromptKind, README_FILE_KIND_MAP} from '@truenine/plugin-s 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']) diff --git a/cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts b/cli/src/inputs/input-rule.ts similarity index 93% rename from cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts rename to cli/src/inputs/input-rule.ts index 9f3aff42..c2d07f4f 100644 --- a/cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts +++ b/cli/src/inputs/input-rule.ts @@ -38,9 +38,9 @@ export class RuleInputPlugin extends AbstractInputPlugin { const srcDir = this.getSrcDir(options, resolvedPaths) const distDir = this.getDistDir(options, resolvedPaths) - const reader = createLocalizedPromptReader(fs, path, logger, globalScope) // Use LocalizedPromptReader for flat file structure with new architecture + const reader = createLocalizedPromptReader(fs, path, logger, globalScope) - const {prompts: localizedRulesFromSrc, errors} = await reader.readFlatFiles( // First, try to read from src directory structure (new way) + const {prompts: localizedRulesFromSrc, errors} = await reader.readFlatFiles( srcDir, distDir, { @@ -48,7 +48,7 @@ export class RuleInputPlugin extends AbstractInputPlugin { localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, isDirectoryStructure: false, createPrompt: async (content, _locale, name) => { - const srcFilePath = path.join(srcDir, `${name}.cn.mdx`) // For src files, extract metadata from the compiled content's source + const srcFilePath = path.join(srcDir, `${name}.cn.mdx`) let globs: readonly string[] = [] let scope: RuleScope = 'project' let seriName: string | undefined, @@ -96,7 +96,7 @@ export class RuleInputPlugin extends AbstractInputPlugin { } ) - const legacyRules: RulePrompt[] = [] // Also scan dist directory for legacy nested structure (backward compatibility) + const legacyRules: RulePrompt[] = [] const localizedRules: LocalizedRulePrompt[] = [...localizedRulesFromSrc] if (fs.existsSync(distDir)) { @@ -109,7 +109,7 @@ export class RuleInputPlugin extends AbstractInputPlugin { const seriesName = entry.name const seriesDir = path.join(distDir, seriesName) - const alreadyProcessed = localizedRulesFromSrc.some(r => r.name.startsWith(`${seriesName}/`)) // Skip if already processed from src + const alreadyProcessed = localizedRulesFromSrc.some(r => r.name.startsWith(`${seriesName}/`)) if (alreadyProcessed) continue try { @@ -122,13 +122,13 @@ export class RuleInputPlugin extends AbstractInputPlugin { const name = `${seriesName}/${baseName}` const distFilePath = path.join(seriesDir, file.name) - if (localizedRulesFromSrc.some(r => r.name === name)) continue // Check if we already have this from src + if (localizedRulesFromSrc.some(r => r.name === name)) continue try { const rawContent = fs.readFileSync(distFilePath, 'utf8') const parsed = parseMarkdown(rawContent) - const content = globalScope != null ? await mdxToMd(rawContent, {globalScope, basePath: seriesDir}) : parsed.contentWithoutFrontMatter ?? rawContent // Compile if needed + const content = globalScope != null ? await mdxToMd(rawContent, {globalScope, basePath: seriesDir}) : parsed.contentWithoutFrontMatter ?? rawContent const {yamlFrontMatter} = parsed const globs = (yamlFrontMatter?.['globs'] as string[]) ?? [] diff --git a/cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.ts b/cli/src/inputs/input-shadow-project.ts similarity index 98% rename from cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.ts rename to cli/src/inputs/input-shadow-project.ts index ffcc7e2f..73b1d294 100644 --- a/cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.ts +++ b/cli/src/inputs/input-shadow-project.ts @@ -2,9 +2,7 @@ import type {CollectedInputContext, InputPluginContext, Project, Workspace} from import type {ProjectConfig} from '@truenine/plugin-shared/types' import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind -} from '@truenine/plugin-shared' +import {FilePathKind} from '@truenine/plugin-shared' import {parse as parseJsonc} from 'jsonc-parser' export class ShadowProjectInputPlugin extends AbstractInputPlugin { diff --git a/cli/src/plugins/plugin-input-shared-ignore/AIAgentIgnoreInputPlugin.ts b/cli/src/inputs/input-shared-ignore.ts similarity index 87% rename from cli/src/plugins/plugin-input-shared-ignore/AIAgentIgnoreInputPlugin.ts rename to cli/src/inputs/input-shared-ignore.ts index 2cfb14b4..206c85a2 100644 --- a/cli/src/plugins/plugin-input-shared-ignore/AIAgentIgnoreInputPlugin.ts +++ b/cli/src/inputs/input-shared-ignore.ts @@ -12,11 +12,6 @@ const IGNORE_FILE_NAMES: readonly string[] = [ '.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') diff --git a/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts b/cli/src/inputs/input-subagent.ts similarity index 100% rename from cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts rename to cli/src/inputs/input-subagent.ts diff --git a/cli/src/plugins/plugin-input-vscode-config/VSCodeConfigInputPlugin.ts b/cli/src/inputs/input-vscode-config.ts similarity index 100% rename from cli/src/plugins/plugin-input-vscode-config/VSCodeConfigInputPlugin.ts rename to cli/src/inputs/input-vscode-config.ts diff --git a/cli/src/plugins/plugin-input-workspace/WorkspaceInputPlugin.ts b/cli/src/inputs/input-workspace.ts similarity index 93% rename from cli/src/plugins/plugin-input-workspace/WorkspaceInputPlugin.ts rename to cli/src/inputs/input-workspace.ts index 2c46ad25..ea4add6c 100644 --- a/cli/src/plugins/plugin-input-workspace/WorkspaceInputPlugin.ts +++ b/cli/src/inputs/input-workspace.ts @@ -1,9 +1,7 @@ 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' +import {FilePathKind} from '@truenine/plugin-shared' export class WorkspaceInputPlugin extends AbstractInputPlugin { constructor() { diff --git a/cli/src/plugin.config.ts b/cli/src/plugin.config.ts index 1657877d..a1e43da1 100644 --- a/cli/src/plugin.config.ts +++ b/cli/src/plugin.config.ts @@ -7,24 +7,6 @@ import {DroidCLIOutputPlugin} from '@truenine/plugin-droid-cli' import {EditorConfigOutputPlugin} from '@truenine/plugin-editorconfig' import {GeminiCLIOutputPlugin} from '@truenine/plugin-gemini-cli' import {GitExcludeOutputPlugin} from '@truenine/plugin-git-exclude' -import {SkillInputPlugin} from '@truenine/plugin-input-agentskills' -import {EditorConfigInputPlugin} from '@truenine/plugin-input-editorconfig' -import {FastCommandInputPlugin} from '@truenine/plugin-input-fast-command' -import {GitExcludeInputPlugin} from '@truenine/plugin-input-git-exclude' -import {GitIgnoreInputPlugin} from '@truenine/plugin-input-gitignore' -import {GlobalMemoryInputPlugin} from '@truenine/plugin-input-global-memory' -import {JetBrainsConfigInputPlugin} from '@truenine/plugin-input-jetbrains-config' -import {MarkdownWhitespaceCleanupEffectInputPlugin} from '@truenine/plugin-input-md-cleanup-effect' -import {OrphanFileCleanupEffectInputPlugin} from '@truenine/plugin-input-orphan-cleanup-effect' -import {ProjectPromptInputPlugin} from '@truenine/plugin-input-project-prompt' -import {ReadmeMdInputPlugin} from '@truenine/plugin-input-readme' -import {RuleInputPlugin} from '@truenine/plugin-input-rule' -import {ShadowProjectInputPlugin} from '@truenine/plugin-input-shadow-project' -import {AIAgentIgnoreInputPlugin} from '@truenine/plugin-input-shared-ignore' -import {SkillNonSrcFileSyncEffectInputPlugin} from '@truenine/plugin-input-skill-sync-effect' -import {SubAgentInputPlugin} from '@truenine/plugin-input-subagent' -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 {CodexCLIOutputPlugin} from '@truenine/plugin-openai-codex-cli' @@ -36,6 +18,26 @@ import {VisualStudioCodeIDEConfigOutputPlugin} from '@truenine/plugin-vscode' import {WarpIDEOutputPlugin} from '@truenine/plugin-warp-ide' import {WindsurfOutputPlugin} from '@truenine/plugin-windsurf' import {defineConfig} from '@/config' +import { + AIAgentIgnoreInputPlugin, + EditorConfigInputPlugin, + FastCommandInputPlugin, + GitExcludeInputPlugin, + GitIgnoreInputPlugin, + GlobalMemoryInputPlugin, + JetBrainsConfigInputPlugin, + MarkdownWhitespaceCleanupEffectInputPlugin, + OrphanFileCleanupEffectInputPlugin, + ProjectPromptInputPlugin, + ReadmeMdInputPlugin, + RuleInputPlugin, + ShadowProjectInputPlugin, + SkillInputPlugin, + SkillNonSrcFileSyncEffectInputPlugin, + SubAgentInputPlugin, + VSCodeConfigInputPlugin, + WorkspaceInputPlugin +} from '@/inputs' export default defineConfig({ plugins: [ diff --git a/cli/src/plugins/plugin-input-agentskills/ResourceProcessor.ts b/cli/src/plugins/plugin-input-agentskills/ResourceProcessor.ts deleted file mode 100644 index 18da745e..00000000 --- a/cli/src/plugins/plugin-input-agentskills/ResourceProcessor.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type {ILogger, SkillChildDoc, SkillResource, SkillResourceEncoding} from '@truenine/plugin-shared' -import type {Dirent} from 'node:fs' -import {Buffer} from 'node:buffer' -import * as nodePath from 'node:path' -import {parseMarkdown, transformMdxReferencesToMd} from '@truenine/md-compiler/markdown' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import {getMimeType, getResourceCategory, isBinaryResourceExtension} from './config/fileTypes' - -/** - * Portable path join that works with Unix-style paths in tests across all platforms - */ -function pathJoin(...segments: string[]): string { - const joined = nodePath.join(...segments) // Normalize to forward slashes for consistent behavior - return joined.replaceAll('\\', '/') -} - -export interface ResourceScanResult { - readonly childDocs: SkillChildDoc[] - readonly resources: SkillResource[] -} - -export interface ResourceProcessorContext { - readonly fs: typeof import('node:fs') - readonly logger: ILogger - readonly skillDir: string -} - -/** - * Resource processor for scanning and processing skill directory contents - * Extracted from SkillInputPlugin to reduce complexity and nesting - */ -export class ResourceProcessor { - private readonly ctx: ResourceProcessorContext - - constructor(ctx: ResourceProcessorContext) { - this.ctx = ctx - } - - processDirectory(entry: Dirent, currentRelativePath: string, filePath: string): ResourceScanResult { - const relativePath = currentRelativePath - ? `${currentRelativePath}/${entry.name}` - : entry.name - - return this.scanSkillDirectory(filePath, relativePath) - } - - processFile(entry: Dirent, currentRelativePath: string, filePath: string): ResourceScanResult { - const relativePath = currentRelativePath - ? `${currentRelativePath}/${entry.name}` - : entry.name - - if (currentRelativePath === '' && entry.name === 'skill.mdx') { // Skip skill.mdx in root directory (handled separately) - return {childDocs: [], resources: []} - } - - if (currentRelativePath === '' && entry.name === 'mcp.json') { // Skip mcp.json in root directory (handled separately) - return {childDocs: [], resources: []} - } - - if (entry.name.endsWith('.mdx')) { - const childDoc = this.processChildDoc(entry.name, relativePath, filePath) - return {childDocs: childDoc ? [childDoc] : [], resources: []} - } - - const resource = this.processResourceFile(entry.name, relativePath, filePath) - return {childDocs: [], resources: resource ? [resource] : []} - } - - private processChildDoc(_fileName: string, relativePath: string, filePath: string): SkillChildDoc | null { - try { - const rawContent = this.ctx.fs.readFileSync(filePath, 'utf8') - const parsed = parseMarkdown(rawContent) - const content = transformMdxReferencesToMd(parsed.contentWithoutFrontMatter) - - return { - 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: this.ctx.skillDir, - getDirectoryName: () => nodePath.dirname(relativePath), - getAbsolutePath: () => filePath - } - } - } - catch (e) { - this.ctx.logger.warn('failed to read child doc', {path: relativePath, error: e}) - return null - } - } - - private processResourceFile(fileName: string, relativePath: string, filePath: string): SkillResource | null { - const ext = nodePath.extname(fileName) - - try { - const {content, encoding, length} = this.readFileContent(filePath, ext) - const mimeType = getMimeType(ext) - - const resource: SkillResource = { - type: PromptKind.SkillResource, - extension: ext, - fileName, - relativePath, - content, - encoding, - category: getResourceCategory(ext), - length, - ...mimeType != null && {mimeType} - } - - return resource - } - catch (e) { - this.ctx.logger.warn('failed to read resource file', {path: relativePath, error: e}) - return null - } - } - - private readFileContent(filePath: string, ext: string): {content: string, encoding: SkillResourceEncoding, length: number} { - if (isBinaryResourceExtension(ext)) { - const buffer = this.ctx.fs.readFileSync(filePath) - return { - content: buffer.toString('base64'), - encoding: 'base64', - length: buffer.length - } - } - - const content = this.ctx.fs.readFileSync(filePath, 'utf8') - return { - content, - encoding: 'text', - length: Buffer.from(content, 'utf8').length - } - } - - scanSkillDirectory(currentDir: string, currentRelativePath: string = ''): ResourceScanResult { - const childDocs: SkillChildDoc[] = [] - const resources: SkillResource[] = [] - - let entries: Dirent[] - try { - entries = this.ctx.fs.readdirSync(currentDir, {withFileTypes: true}) - } - catch (e) { - this.ctx.logger.warn('failed to scan directory', {path: currentDir, error: e}) - return {childDocs, resources} - } - - for (const entry of entries) { - const filePath = pathJoin(currentDir, entry.name) - - if (entry.isDirectory()) { - const subResult = this.processDirectory(entry, currentRelativePath, filePath) - childDocs.push(...subResult.childDocs) - resources.push(...subResult.resources) - continue - } - - if (!entry.isFile()) continue - - const fileResult = this.processFile(entry, currentRelativePath, filePath) - childDocs.push(...fileResult.childDocs) - resources.push(...fileResult.resources) - } - - return {childDocs, resources} - } -} diff --git a/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.test.ts b/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.test.ts deleted file mode 100644 index be4aee01..00000000 --- a/cli/src/plugins/plugin-input-agentskills/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/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts b/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts deleted file mode 100644 index 07293329..00000000 --- a/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts +++ /dev/null @@ -1,349 +0,0 @@ -import type { - CollectedInputContext, - ILogger, - InputPluginContext, - LocalizedPrompt, - LocalizedSkillPrompt, - McpServerConfig, - SkillMcpConfig, - SkillPrompt, - SkillYAMLFrontMatter -} from '@truenine/plugin-shared' -import type {ResourceScanResult} from './ResourceProcessor' -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, createLocalizedPromptReader} from '@truenine/plugin-input-shared' -import {FilePathKind, PromptKind, validateSkillMetadata} from '@truenine/plugin-shared' -import {ResourceProcessor} from './ResourceProcessor' - -export { - getResourceCategory, - isBinaryResourceExtension -} from './config/fileTypes' - -/** - * Read MCP configuration from mcp.json file - */ -function readMcpConfig( - skillDir: string, - fs: typeof import('node:fs'), - logger: ILogger -): SkillMcpConfig | undefined { - 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 - } -} - -/** - * Create SkillPrompt from compiled content - */ -async function createSkillPrompt( - content: string, - _locale: 'zh' | 'en', - name: string, - skillDir: string, - skillAbsoluteDir: string, - ctx: InputPluginContext, - mcpConfig?: SkillMcpConfig, - childDocs: SkillPrompt['childDocs'] = [], - resources: SkillPrompt['resources'] = [], - seriName?: string | string[] | null -): Promise { - const {logger, globalScope, fs} = ctx - - const srcFilePath = path.join(skillAbsoluteDir, 'skill.cn.mdx') // Find the source file to get metadata - let rawContent = content - let parsed: ReturnType> | undefined - - if (fs.existsSync(srcFilePath)) { - try { - rawContent = fs.readFileSync(srcFilePath, 'utf8') - parsed = parseMarkdown(rawContent) - - const compileResult = await mdxToMd(rawContent, { // Re-compile if reading from source - globalScope, - extractMetadata: true, - basePath: skillAbsoluteDir - }) - - content = transformMdxReferencesToMd(compileResult.content) - } - catch (e) { - logger.warn('failed to recompile skill from source', {skill: name, error: e}) - } - } - - const mergedFrontMatter: SkillYAMLFrontMatter = { - ...parsed?.yamlFrontMatter ?? {}, - name, - description: '' - } as SkillYAMLFrontMatter - - return { // Build result with all optional fields using spread - type: PromptKind.Skill, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - yamlFrontMatter: mergedFrontMatter, - markdownAst: parsed?.markdownAst, - markdownContents: parsed?.markdownContents ?? [], - dir: { - pathKind: FilePathKind.Relative, - path: name, - basePath: skillDir, - getDirectoryName: () => name, - getAbsolutePath: () => path.join(skillDir, name) - }, - ...parsed?.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - ...mcpConfig != null && {mcpConfig}, - ...childDocs != null && childDocs.length > 0 && {childDocs}, - ...resources != null && resources.length > 0 && {resources}, - ...seriName != null && {seriName} - } as SkillPrompt -} - -/** - * Process skill file and extract metadata (legacy, for backward compatibility) - */ -async function processSkillFile( - skillFilePath: string, - skillDir: string, - entryName: string, - skillAbsoluteDir: string, - ctx: InputPluginContext -): Promise { - const {logger, globalScope, fs} = ctx - - let rawContent: string - try { - rawContent = fs.readFileSync(skillFilePath, 'utf8') - } - catch (e) { - logger.error('failed to read skill file', {file: skillFilePath, error: e}) - return null - } - - let parsed: ReturnType> - try { - parsed = parseMarkdown(rawContent) - } - catch (e) { - logger.error('failed to parse skill markdown', {file: skillFilePath, error: e}) - return null - } - - let compileResult: Awaited> - try { - compileResult = await mdxToMd(rawContent, { - globalScope, - extractMetadata: true, - basePath: skillAbsoluteDir - }) - } - catch (e) { - logger.error('failed to compile skill mdx', {file: skillFilePath, error: e}) - return null - } - - 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) - - logger.debug('skill metadata extracted', { - skill: entryName, - source: compileResult.metadata.source, - hasYaml: parsed.yamlFrontMatter != null, - hasExport: Object.keys(compileResult.metadata.fields).length > 0 - }) - - const processor = new ResourceProcessor({fs, logger, skillDir: skillAbsoluteDir}) - const {childDocs, resources} = processor.scanSkillDirectory(skillAbsoluteDir) - const mcpConfig = readMcpConfig(skillAbsoluteDir, fs, logger) - - const {seriName} = mergedFrontMatter - - return { - type: PromptKind.Skill, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - yamlFrontMatter: mergedFrontMatter.name != null - ? mergedFrontMatter - : {name: entryName, 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: entryName, - basePath: skillDir, - getDirectoryName: () => entryName, - getAbsolutePath: () => path.join(skillDir, entryName) - } - } -} - -export class SkillInputPlugin extends AbstractInputPlugin { - constructor() { - super('SkillInputPlugin') - } - - readMcpConfig( - skillDir: string, - fs: typeof import('node:fs'), - logger: ILogger - ): SkillMcpConfig | undefined { - return readMcpConfig(skillDir, fs, logger) - } - - scanSkillDirectory( - skillDir: string, - fs: typeof import('node:fs'), - logger: ILogger, - currentRelativePath: string = '' - ): ResourceScanResult { - const processor = new ResourceProcessor({fs, logger, skillDir}) - return processor.scanSkillDirectory(skillDir, currentRelativePath) - } - - async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger, fs, path: pathModule, globalScope} = ctx - const {shadowProjectDir} = this.resolveBasePaths(options) - - const srcSkillDir = this.resolveShadowPath(options.shadowSourceProject.skill.src, shadowProjectDir) // Get both src and dist paths - const distSkillDir = this.resolveShadowPath(options.shadowSourceProject.skill.dist, shadowProjectDir) - - const legacySkills: SkillPrompt[] = [] - - const reader = createLocalizedPromptReader(fs, pathModule, logger, globalScope) // Use LocalizedPromptReader for new architecture - - const {prompts: localizedSkills, errors} = await reader.readDirectoryStructure( - srcSkillDir, - distSkillDir, - { - kind: PromptKind.Skill, - entryFileName: 'skill', - localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, - isDirectoryStructure: true, - createPrompt: async (content, locale, name) => { - const skillSrcDir = pathModule.join(srcSkillDir, name) // Get extras from source directory - - const processor = new ResourceProcessor({fs, logger, skillDir: skillSrcDir}) - const {childDocs, resources} = processor.scanSkillDirectory(skillSrcDir) - const mcpConfig = readMcpConfig(skillSrcDir, fs, logger) - - return createSkillPrompt( - content, - locale, - name, - distSkillDir, - skillSrcDir, - ctx, - mcpConfig, - childDocs, - resources - ) - } - } - ) - - for (const error of errors) { // Log errors but don't fail - logger.warn('Failed to read skill', {path: error.path, phase: error.phase, error: error.error}) - } - - for (const localized of localizedSkills) { // Build legacy skills array from localized prompts (for backward compatibility) - const prompt = localized.dist?.prompt ?? localized.src.default.prompt // Prefer dist content, fallback to src.default - - if (prompt) legacySkills.push(prompt) - } - - if (fs.existsSync(distSkillDir)) { // Also scan dist directory for skills that might not have src (edge case) - const distEntries = fs.readdirSync(distSkillDir, {withFileTypes: true}) - const existingNames = new Set(localizedSkills.map(s => s.name)) - - for (const entry of distEntries) { - if (!entry.isDirectory()) continue - if (existingNames.has(entry.name)) continue - - const entryName = entry.name - const skillFilePath = pathModule.join(distSkillDir, entryName, 'skill.mdx') - const skillAbsoluteDir = pathModule.join(distSkillDir, entryName) - - if (!fs.existsSync(skillFilePath)) continue - - try { - const skill = await processSkillFile( - skillFilePath, - distSkillDir, - entryName, - skillAbsoluteDir, - ctx - ) - if (skill) legacySkills.push(skill) - } - catch (e) { - logger.error('failed to parse skill', {file: skillFilePath, error: e}) - } - } - } - - const promptIndex = new Map() // Build prompt index - for (const skill of localizedSkills) promptIndex.set(skill.name, skill) - - return { - prompts: { // New architecture - partial prompts context (other arrays filled by other plugins) - skills: localizedSkills as LocalizedSkillPrompt[], - commands: [], - subAgents: [], - rules: [], - readme: [] - }, - promptIndex, - - skills: legacySkills // Legacy (backward compatibility) - } - } -} diff --git a/cli/src/plugins/plugin-input-agentskills/config/fileTypes.ts b/cli/src/plugins/plugin-input-agentskills/config/fileTypes.ts deleted file mode 100644 index e1c8752a..00000000 --- a/cli/src/plugins/plugin-input-agentskills/config/fileTypes.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * File type categorization configuration - * Centralizes extension definitions to reduce code duplication - */ - -export interface FileTypeCategories { - readonly image: readonly string[] - readonly code: readonly string[] - readonly data: readonly string[] - readonly document: readonly string[] - readonly config: readonly string[] - readonly script: readonly string[] - readonly binary: readonly string[] -} - -export const FILE_TYPE_CATEGORIES: FileTypeCategories = { - image: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.tiff', '.svg'], - code: [ - '.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' - ], - data: ['.sql', '.json', '.jsonc', '.json5', '.xml', '.xsd', '.xsl', '.xslt', '.yaml', '.yml', '.toml', '.csv', '.tsv', '.graphql', '.gql', '.proto'], - document: ['.txt', '.text', '.rtf', '.log', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt', '.pdf', '.odt', '.ods', '.odp'], - config: ['.ini', '.conf', '.cfg', '.config', '.properties', '.env', '.envrc', '.editorconfig', '.gitignore', '.gitattributes', '.npmrc', '.nvmrc', '.npmignore', '.eslintrc', '.prettierrc', '.stylelintrc', '.babelrc', '.browserslistrc'], - script: ['.sh', '.bash', '.zsh', '.fish', '.ps1', '.psm1', '.psd1', '.bat', '.cmd'], - binary: ['.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'] -} as const - -export const SKILL_RESOURCE_BINARY_EXTENSIONS: readonly string[] = [ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.ico', - '.bmp', - '.tiff', - '.svg', - '.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', - '.pdf', - '.docx', - '.doc', - '.xlsx', - '.xls', - '.pptx', - '.ppt', - '.odt', - '.ods', - '.odp' -] as const - -export type ResourceCategory = 'image' | 'code' | 'data' | 'document' | 'config' | 'script' | 'binary' | 'other' -export type ResourceEncoding = 'text' | 'base64' - -/** - * Get resource category based on file extension - */ -export function getResourceCategory(ext: string): ResourceCategory { - const lowerExt = ext.toLowerCase() - - if (FILE_TYPE_CATEGORIES.image.includes(lowerExt)) return 'image' - if (FILE_TYPE_CATEGORIES.code.includes(lowerExt)) return 'code' - if (FILE_TYPE_CATEGORIES.data.includes(lowerExt)) return 'data' - if (FILE_TYPE_CATEGORIES.document.includes(lowerExt)) return 'document' - if (FILE_TYPE_CATEGORIES.config.includes(lowerExt)) return 'config' - if (FILE_TYPE_CATEGORIES.script.includes(lowerExt)) return 'script' - if (FILE_TYPE_CATEGORIES.binary.includes(lowerExt)) return 'binary' - - return 'other' -} - -/** - * Check if extension is a binary resource type - */ -export function isBinaryResourceExtension(ext: string): boolean { - return SKILL_RESOURCE_BINARY_EXTENSIONS.includes(ext.toLowerCase()) -} - -/** - * Common MIME types for resources - */ -export const MIME_TYPES: 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' -} as const - -/** - * Get MIME type for file extension - */ -export function getMimeType(ext: string): string | undefined { - return MIME_TYPES[ext.toLowerCase()] -} diff --git a/cli/src/plugins/plugin-input-agentskills/index.ts b/cli/src/plugins/plugin-input-agentskills/index.ts deleted file mode 100644 index 25f6e244..00000000 --- a/cli/src/plugins/plugin-input-agentskills/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - SkillInputPlugin -} from './SkillInputPlugin' diff --git a/cli/src/plugins/plugin-input-editorconfig/index.ts b/cli/src/plugins/plugin-input-editorconfig/index.ts deleted file mode 100644 index 87495147..00000000 --- a/cli/src/plugins/plugin-input-editorconfig/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index c223fd7e..00000000 --- a/cli/src/plugins/plugin-input-fast-command/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/cli/src/plugins/plugin-input-fast-command/index.ts b/cli/src/plugins/plugin-input-fast-command/index.ts deleted file mode 100644 index 3bf19feb..00000000 --- a/cli/src/plugins/plugin-input-fast-command/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index a9199200..00000000 --- a/cli/src/plugins/plugin-input-git-exclude/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: 'aindex', - 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 deleted file mode 100644 index 1f2560f9..00000000 --- a/cli/src/plugins/plugin-input-git-exclude/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/cli/src/plugins/plugin-input-git-exclude/index.ts b/cli/src/plugins/plugin-input-git-exclude/index.ts deleted file mode 100644 index 7072ab18..00000000 --- a/cli/src/plugins/plugin-input-git-exclude/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 6ead0522..00000000 --- a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts +++ /dev/null @@ -1,64 +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: 'aindex', - 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() - - expect(result).toEqual({}) - }) -}) diff --git a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts deleted file mode 100644 index 987841dd..00000000 --- a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts +++ /dev/null @@ -1,20 +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 gitignore content from shadow source project. - */ -export class GitIgnoreInputPlugin extends BaseFileInputPlugin { - constructor() { - super('GitIgnoreInputPlugin') - } - - 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 deleted file mode 100644 index 2a4ce65d..00000000 --- a/cli/src/plugins/plugin-input-gitignore/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - GitIgnoreInputPlugin -} from './GitIgnoreInputPlugin' diff --git a/cli/src/plugins/plugin-input-global-memory/index.ts b/cli/src/plugins/plugin-input-global-memory/index.ts deleted file mode 100644 index 963f6224..00000000 --- a/cli/src/plugins/plugin-input-global-memory/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - GlobalMemoryInputPlugin -} from './GlobalMemoryInputPlugin' diff --git a/cli/src/plugins/plugin-input-jetbrains-config/index.ts b/cli/src/plugins/plugin-input-jetbrains-config/index.ts deleted file mode 100644 index aab7350c..00000000 --- a/cli/src/plugins/plugin-input-jetbrains-config/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index b7cf2497..00000000 --- a/cli/src/plugins/plugin-input-md-cleanup-effect/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/cli/src/plugins/plugin-input-md-cleanup-effect/index.ts b/cli/src/plugins/plugin-input-md-cleanup-effect/index.ts deleted file mode 100644 index 1ec6b1bc..00000000 --- a/cli/src/plugins/plugin-input-md-cleanup-effect/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 9b5551d2..00000000 --- a/cli/src/plugins/plugin-input-orphan-cleanup-effect/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/cli/src/plugins/plugin-input-orphan-cleanup-effect/index.ts b/cli/src/plugins/plugin-input-orphan-cleanup-effect/index.ts deleted file mode 100644 index 52362bdb..00000000 --- a/cli/src/plugins/plugin-input-orphan-cleanup-effect/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 281125ab..00000000 --- a/cli/src/plugins/plugin-input-project-prompt/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/cli/src/plugins/plugin-input-project-prompt/index.ts b/cli/src/plugins/plugin-input-project-prompt/index.ts deleted file mode 100644 index 697fcfee..00000000 --- a/cli/src/plugins/plugin-input-project-prompt/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 630fba39..00000000 --- a/cli/src/plugins/plugin-input-readme/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/cli/src/plugins/plugin-input-readme/index.ts b/cli/src/plugins/plugin-input-readme/index.ts deleted file mode 100644 index b3b2628f..00000000 --- a/cli/src/plugins/plugin-input-readme/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index d0786955..00000000 --- a/cli/src/plugins/plugin-input-rule/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/cli/src/plugins/plugin-input-rule/index.ts b/cli/src/plugins/plugin-input-rule/index.ts deleted file mode 100644 index aa91bf07..00000000 --- a/cli/src/plugins/plugin-input-rule/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index b0380f19..00000000 --- a/cli/src/plugins/plugin-input-shadow-project/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/cli/src/plugins/plugin-input-shadow-project/index.ts b/cli/src/plugins/plugin-input-shadow-project/index.ts deleted file mode 100644 index 04ecc9ad..00000000 --- a/cli/src/plugins/plugin-input-shadow-project/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - ShadowProjectInputPlugin -} from './ShadowProjectInputPlugin' diff --git a/cli/src/plugins/plugin-input-shared-ignore/index.ts b/cli/src/plugins/plugin-input-shared-ignore/index.ts deleted file mode 100644 index 64bf7709..00000000 --- a/cli/src/plugins/plugin-input-shared-ignore/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - AIAgentIgnoreInputPlugin -} from './AIAgentIgnoreInputPlugin' 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 deleted file mode 100644 index 0ec8b9f2..00000000 --- a/cli/src/plugins/plugin-input-skill-sync-effect/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/cli/src/plugins/plugin-input-skill-sync-effect/index.ts b/cli/src/plugins/plugin-input-skill-sync-effect/index.ts deleted file mode 100644 index 7b4c1a24..00000000 --- a/cli/src/plugins/plugin-input-skill-sync-effect/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 9873347b..00000000 --- a/cli/src/plugins/plugin-input-subagent/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/cli/src/plugins/plugin-input-subagent/index.ts b/cli/src/plugins/plugin-input-subagent/index.ts deleted file mode 100644 index 055e0454..00000000 --- a/cli/src/plugins/plugin-input-subagent/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - SubAgentInputPlugin -} from './SubAgentInputPlugin' -export type { - SubAgentSeriesInfo -} from './SubAgentInputPlugin' diff --git a/cli/src/plugins/plugin-input-vscode-config/index.ts b/cli/src/plugins/plugin-input-vscode-config/index.ts deleted file mode 100644 index 0d16869b..00000000 --- a/cli/src/plugins/plugin-input-vscode-config/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - VSCodeConfigInputPlugin -} from './VSCodeConfigInputPlugin' diff --git a/cli/src/plugins/plugin-input-workspace/index.ts b/cli/src/plugins/plugin-input-workspace/index.ts deleted file mode 100644 index 10051289..00000000 --- a/cli/src/plugins/plugin-input-workspace/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { - WorkspaceInputPlugin -} from './WorkspaceInputPlugin' diff --git a/cli/tsconfig.json b/cli/tsconfig.json index fc403126..120d56e1 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -34,24 +34,6 @@ "@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-openai-codex-cli": ["./src/plugins/plugin-openai-codex-cli/index.ts"], From fc175aa802c9c794ecd958a0ee9302cde3e839c9 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 3 Mar 2026 01:08:23 +0800 Subject: [PATCH 14/30] =?UTF-8?q?feat(plugin-trae-cn-ide):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=B8=AD=E6=96=87=E7=89=88TraeIDE=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(inputs): 统一使用dist路径替代src路径读取文件 重构输入插件以优先读取dist目录下的文件,提升编译后内容的复用性 fix(plugin-shared): 完善技能元数据校验逻辑 增加对必填字段的严格校验,确保描述字段不为空 feat(plugin-trae-ide): 增强技能输出功能 新增技能文件及资源输出支持,优化命令输出路径处理 --- cli/src/inputs/input-agentskills.ts | 100 +++++- cli/src/inputs/input-fast-command.ts | 8 +- cli/src/inputs/input-rule.ts | 4 +- cli/src/inputs/input-subagent.ts | 8 +- cli/src/plugin.config.ts | 2 + .../LocalizedPromptReader.ts | 70 +++- cli/src/plugins/plugin-shared/PluginNames.ts | 1 + .../types/ExportMetadataTypes.ts | 32 +- .../TraeCNIDEOutputPlugin.ts | 72 +++++ cli/src/plugins/plugin-trae-cn-ide/index.ts | 3 + .../TraeIDEOutputPlugin.test.ts | 10 +- .../plugin-trae-ide/TraeIDEOutputPlugin.ts | 306 +++++++++++++++--- 12 files changed, 526 insertions(+), 90 deletions(-) create mode 100644 cli/src/plugins/plugin-trae-cn-ide/TraeCNIDEOutputPlugin.ts create mode 100644 cli/src/plugins/plugin-trae-cn-ide/index.ts diff --git a/cli/src/inputs/input-agentskills.ts b/cli/src/inputs/input-agentskills.ts index ce484495..8d9a5c26 100644 --- a/cli/src/inputs/input-agentskills.ts +++ b/cli/src/inputs/input-agentskills.ts @@ -25,6 +25,58 @@ import {FilePathKind, PromptKind, validateSkillMetadata} from '@truenine/plugin- export * from './input-agentskills-types' // Re-export from types file +interface WritableSkillMetadata { + name?: string + description?: string + displayName?: string + keywords?: string[] + author?: string + version?: string + allowTools?: string[] +} + +const EXPORT_DEFAULT_REGEX = /export\s+default\s*\{([\s\S]*?)\}/u +const DESCRIPTION_REGEX = /description\s*:\s*['"`]([^'"`]+)['"`]/u +const NAME_REGEX = /name\s*:\s*['"`]([^'"`]+)['"`]/u +const DISPLAY_NAME_REGEX = /displayName\s*:\s*['"`]([^'"`]+)['"`]/u +const KEYWORDS_REGEX = /keywords\s*:\s*\[([^\]]+)\]/u +const AUTHOR_REGEX = /author\s*:\s*['"`]([^'"`]+)['"`]/u +const VERSION_REGEX = /version\s*:\s*['"`]([^'"`]+)['"`]/u + +function extractSkillMetadataFromExport(content: string): WritableSkillMetadata { + const metadata: WritableSkillMetadata = {} + + const exportMatch = EXPORT_DEFAULT_REGEX.exec(content) + if (exportMatch?.[1] == null) return metadata + + const objectContent = exportMatch[1] + + const descriptionMatch = DESCRIPTION_REGEX.exec(objectContent) + if (descriptionMatch?.[1] != null) metadata.description = descriptionMatch[1] + + const nameMatch = NAME_REGEX.exec(objectContent) + if (nameMatch?.[1] != null) metadata.name = nameMatch[1] + + const displayNameMatch = DISPLAY_NAME_REGEX.exec(objectContent) + if (displayNameMatch?.[1] != null) metadata.displayName = displayNameMatch[1] + + const keywordsMatch = KEYWORDS_REGEX.exec(objectContent) + if (keywordsMatch?.[1] != null) { + metadata.keywords = keywordsMatch[1] + .split(',') + .map(k => k.trim().replaceAll(/['"]/gu, '')) + .filter(k => k.length > 0) + } + + const authorMatch = AUTHOR_REGEX.exec(objectContent) + if (authorMatch?.[1] != null) metadata.author = authorMatch[1] + + const versionMatch = VERSION_REGEX.exec(objectContent) + if (versionMatch?.[1] != null) metadata.version = versionMatch[1] + + return metadata +} + const MIME_TYPES: Record = { // MIME types for resources '.ts': 'text/typescript', '.tsx': 'text/typescript', @@ -343,13 +395,13 @@ async function createSkillPrompt( ): Promise { const {logger, globalScope, fs} = ctx - const srcFilePath = nodePath.join(skillAbsoluteDir, 'skill.cn.mdx') + const distFilePath = nodePath.join(skillAbsoluteDir, 'skill.mdx') let rawContent = content let parsed: ReturnType> | undefined - if (fs.existsSync(srcFilePath)) { + if (fs.existsSync(distFilePath)) { try { - rawContent = fs.readFileSync(srcFilePath, 'utf8') + rawContent = fs.readFileSync(distFilePath, 'utf8') parsed = parseMarkdown(rawContent) const compileResult = await mdxToMd(rawContent, { @@ -361,14 +413,30 @@ async function createSkillPrompt( content = transformMdxReferencesToMd(compileResult.content) } catch (e) { - logger.warn('failed to recompile skill from source', {skill: name, error: e}) + logger.warn('failed to recompile skill from dist', {skill: name, error: e}) } } + const exportMetadata = extractSkillMetadataFromExport(rawContent) // Extract metadata from JS export if YAML front matter is not present + + const finalDescription = parsed?.yamlFrontMatter?.description ?? exportMetadata.description + + if (finalDescription == null || finalDescription.trim().length === 0) { // Strict validation: description must exist and not be empty + logger.error('SKILL_VALIDATION_FAILED: description is required and cannot be empty', { + skill: name, + skillDir, + yamlDescription: parsed?.yamlFrontMatter?.description, + exportDescription: exportMetadata.description, + hint: 'Add a non-empty description field to the SKILL.md front matter or export default' + }) + throw new Error(`Skill "${name}" validation failed: description is required and cannot be empty`) + } + const mergedFrontMatter: SkillYAMLFrontMatter = { + ...exportMetadata, ...parsed?.yamlFrontMatter ?? {}, name, - description: '' + description: finalDescription } as SkillYAMLFrontMatter return { @@ -439,6 +507,18 @@ async function processSkillFile( ...compileResult.metadata.fields } as SkillYAMLFrontMatter + const finalDescription = mergedFrontMatter.description // Strict validation: description must exist and not be empty + if (finalDescription == null || finalDescription.trim().length === 0) { + logger.error('SKILL_VALIDATION_FAILED: description is required and cannot be empty', { + skill: entryName, + skillFilePath, + yamlDescription: parsed.yamlFrontMatter?.description, + exportDescription: compileResult.metadata.fields['description'], + hint: 'Add a non-empty description field to the SKILL.md front matter or export default' + }) + throw new Error(`Skill "${entryName}" validation failed: description is required and cannot be empty`) + } + const validationResult = validateSkillMetadata( mergedFrontMatter as Record, skillFilePath @@ -530,17 +610,17 @@ export class SkillInputPlugin extends AbstractInputPlugin { localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, isDirectoryStructure: true, createPrompt: async (content, locale, name) => { - const skillSrcDir = pathModule.join(srcSkillDir, name) - const processor = new ResourceProcessor({fs, logger, skillDir: skillSrcDir}) - const {childDocs, resources} = processor.scanSkillDirectory(skillSrcDir) - const mcpConfig = readMcpConfig(skillSrcDir, fs, logger) + const skillDistDir = pathModule.join(distSkillDir, name) + const processor = new ResourceProcessor({fs, logger, skillDir: skillDistDir}) + const {childDocs, resources} = processor.scanSkillDirectory(skillDistDir) + const mcpConfig = readMcpConfig(skillDistDir, fs, logger) return createSkillPrompt( content, locale, name, distSkillDir, - skillSrcDir, + skillDistDir, ctx, mcpConfig, childDocs, diff --git a/cli/src/inputs/input-fast-command.ts b/cli/src/inputs/input-fast-command.ts index 49c1fa36..5dee1333 100644 --- a/cli/src/inputs/input-fast-command.ts +++ b/cli/src/inputs/input-fast-command.ts @@ -34,8 +34,8 @@ export class FastCommandInputPlugin extends AbstractInputPlugin { content: string, _locale: Locale, name: string, - srcDir: string, - _distDir: string, + _srcDir: string, + distDir: string, ctx: InputPluginContext, _rawContent?: string ): FastCommandPrompt { @@ -47,7 +47,7 @@ export class FastCommandInputPlugin extends AbstractInputPlugin { const seriesInfo = this.extractSeriesInfo(fileName, parentDirName) - const filePath = path.join(srcDir, `${name}.cn.mdx`) + const filePath = path.join(distDir, `${name}.mdx`) const entryName = `${name}.mdx` return { @@ -58,7 +58,7 @@ export class FastCommandInputPlugin extends AbstractInputPlugin { dir: { pathKind: FilePathKind.Relative, path: entryName, - basePath: srcDir, + basePath: distDir, getDirectoryName: () => entryName.replace(/\.mdx$/, ''), getAbsolutePath: () => filePath }, diff --git a/cli/src/inputs/input-rule.ts b/cli/src/inputs/input-rule.ts index c2d07f4f..2f8fb99d 100644 --- a/cli/src/inputs/input-rule.ts +++ b/cli/src/inputs/input-rule.ts @@ -48,7 +48,7 @@ export class RuleInputPlugin extends AbstractInputPlugin { localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, isDirectoryStructure: false, createPrompt: async (content, _locale, name) => { - const srcFilePath = path.join(srcDir, `${name}.cn.mdx`) + const distFilePath = path.join(distDir, `${name}.mdx`) let globs: readonly string[] = [] let scope: RuleScope = 'project' let seriName: string | undefined, @@ -56,7 +56,7 @@ export class RuleInputPlugin extends AbstractInputPlugin { rawFrontMatter: string | undefined try { - const rawContent = fs.readFileSync(srcFilePath, 'utf8') + const rawContent = fs.readFileSync(distFilePath, 'utf8') const {yamlFrontMatter: yfm, rawFrontMatter: rfm} = parseMarkdown(rawContent) if (yfm) { yamlFrontMatter = yfm diff --git a/cli/src/inputs/input-subagent.ts b/cli/src/inputs/input-subagent.ts index bdc865e0..617ee26e 100644 --- a/cli/src/inputs/input-subagent.ts +++ b/cli/src/inputs/input-subagent.ts @@ -34,8 +34,8 @@ export class SubAgentInputPlugin extends AbstractInputPlugin { content: string, _locale: Locale, name: string, - srcDir: string, - _distDir: string, + _srcDir: string, + distDir: string, ctx: InputPluginContext ): SubAgentPrompt { const {path} = ctx @@ -46,7 +46,7 @@ export class SubAgentInputPlugin extends AbstractInputPlugin { const seriesInfo = this.extractSeriesInfo(fileName, parentDirName) - const filePath = path.join(srcDir, `${name}.cn.mdx`) + const filePath = path.join(distDir, `${name}.mdx`) const entryName = `${name}.mdx` return { @@ -57,7 +57,7 @@ export class SubAgentInputPlugin extends AbstractInputPlugin { dir: { pathKind: FilePathKind.Relative, path: entryName, - basePath: srcDir, + basePath: distDir, getDirectoryName: () => entryName.replace(/\.mdx$/, ''), getAbsolutePath: () => filePath }, diff --git a/cli/src/plugin.config.ts b/cli/src/plugin.config.ts index a1e43da1..cd0de8b9 100644 --- a/cli/src/plugin.config.ts +++ b/cli/src/plugin.config.ts @@ -38,6 +38,7 @@ import { VSCodeConfigInputPlugin, WorkspaceInputPlugin } from '@/inputs' +import {TraeCNIDEOutputPlugin} from '@/plugins/plugin-trae-cn-ide' export default defineConfig({ plugins: [ @@ -52,6 +53,7 @@ export default defineConfig({ new OpencodeCLIOutputPlugin(), new QoderIDEPluginOutputPlugin(), new TraeIDEOutputPlugin(), + new TraeCNIDEOutputPlugin(), new WarpIDEOutputPlugin(), new WindsurfOutputPlugin(), new CursorOutputPlugin(), diff --git a/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts b/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts index 394635d0..2667e3b4 100644 --- a/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts +++ b/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts @@ -164,7 +164,34 @@ export class LocalizedPromptReader { const srcEnPath = this.path.join(srcEntryDir, `${baseFileName}${localeExtensions.en}`) const distPath = this.path.join(distEntryDir, `${baseFileName}.mdx`) - const zhContent = await this.readLocaleContent(srcZhPath, 'zh', createPrompt, name) // Read Chinese source (required) + const distContent = await this.readDistContent(distPath, createPrompt, name) // Priority 1: Try dist first (already compiled, no need to recompile) + if (distContent) { + let children: string[] | undefined // Dist exists, use it directly (skip src entirely) + if (isDirectoryStructure) children = this.scanChildren(distEntryDir, baseFileName, localeExtensions.zh) + + return { + name, + type: kind, + src: { + zh: distContent, + default: distContent, + defaultLocale: 'zh' + }, + dist: distContent, + metadata: { + hasDist: true, + hasMultipleLocales: false, + isDirectoryStructure, + ...children && children.length > 0 && {children} + }, + paths: { + zh: srcZhPath, + dist: distPath + } + } + } + + const zhContent = await this.readLocaleContent(srcZhPath, 'zh', createPrompt, name) // Read Chinese source (required) // Priority 2: Dist not exists, fall back to src if (!zhContent) { this.logger.warn(`Missing required Chinese source: ${srcZhPath}`) return null @@ -172,8 +199,6 @@ export class LocalizedPromptReader { const enContent = await this.readLocaleContent(srcEnPath, 'en', createPrompt, name) // Read English source (optional) - const distContent = await this.readDistContent(distPath, createPrompt, name) // Read dist content (optional, may not exist yet) - const src: LocalizedPrompt['src'] = { zh: zhContent, ...enContent && {en: enContent}, @@ -182,7 +207,6 @@ export class LocalizedPromptReader { } const hasMultipleLocales = !!enContent - const hasDist = !!distContent let children: string[] | undefined // Determine children (for directory structures) if (isDirectoryStructure) children = this.scanChildren(srcEntryDir, baseFileName, localeExtensions.zh) @@ -191,17 +215,15 @@ export class LocalizedPromptReader { name, type: kind, src, - ...distContent && {dist: distContent}, metadata: { - hasDist, + hasDist: false, hasMultipleLocales, isDirectoryStructure, ...children && children.length > 0 && {children} }, paths: { zh: srcZhPath, - ...this.exists(srcEnPath) && {en: srcEnPath}, - ...distContent && {dist: distPath} + ...this.exists(srcEnPath) && {en: srcEnPath} } } } @@ -223,7 +245,29 @@ export class LocalizedPromptReader { const srcEnPath = `${baseName}${localeExtensions.en}` const distPath = this.path.join(distDir, `${name}.mdx`) - const fullSrcZhPath = isSingleFile ? srcZhPath : this.path.join(srcDir, srcZhPath) + const distContent = await this.readDistContent(distPath, createPrompt, name) // Priority 1: Try dist first (already compiled, no need to recompile) + if (distContent) { + return { + name, + type: kind, + src: { + zh: distContent, + default: distContent, + defaultLocale: 'zh' + }, + dist: distContent, + metadata: { + hasDist: true, + hasMultipleLocales: false, + isDirectoryStructure: false + }, + paths: { + dist: distPath + } + } + } + + const fullSrcZhPath = isSingleFile ? srcZhPath : this.path.join(srcDir, srcZhPath) // Priority 2: Dist not exists, fall back to src const fullSrcEnPath = isSingleFile ? srcEnPath : this.path.join(srcDir, srcEnPath) const zhContent = await this.readLocaleContent(fullSrcZhPath, 'zh', createPrompt, name) // Read Chinese source (required) @@ -231,8 +275,6 @@ export class LocalizedPromptReader { const enContent = await this.readLocaleContent(fullSrcEnPath, 'en', createPrompt, name) // Read English source (optional) - const distContent = await this.readDistContent(distPath, createPrompt, name) // Read dist content (optional) - const src: LocalizedPrompt['src'] = { zh: zhContent, ...enContent && {en: enContent}, @@ -244,16 +286,14 @@ export class LocalizedPromptReader { name, type: kind, src, - ...distContent && {dist: distContent}, metadata: { - hasDist: !!distContent, + hasDist: false, hasMultipleLocales: !!enContent, isDirectoryStructure: false }, paths: { zh: fullSrcZhPath, - ...this.exists(fullSrcEnPath) ? {en: fullSrcEnPath} : {}, - ...distContent ? {dist: distPath} : {} + ...this.exists(fullSrcEnPath) ? {en: fullSrcEnPath} : {} } } } diff --git a/cli/src/plugins/plugin-shared/PluginNames.ts b/cli/src/plugins/plugin-shared/PluginNames.ts index 8bc93739..cdb8d302 100644 --- a/cli/src/plugins/plugin-shared/PluginNames.ts +++ b/cli/src/plugins/plugin-shared/PluginNames.ts @@ -10,6 +10,7 @@ export const PLUGIN_NAMES = { DroidCLIOutput: 'DroidCLIOutputPlugin', WarpIDEOutput: 'WarpIDEOutputPlugin', TraeIDEOutput: 'TraeIDEOutputPlugin', + TraeCNIDEOutput: 'TraeCNIDEOutputPlugin', QoderIDEOutput: 'QoderIDEPluginOutputPlugin', JetBrainsCodeStyleOutput: 'JetBrainsIDECodeStyleConfigOutputPlugin', JetBrainsAICodexOutput: 'JetBrainsAIAssistantCodexOutputPlugin', diff --git a/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts b/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts index 361e0c60..d17cc006 100644 --- a/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts +++ b/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts @@ -116,14 +116,30 @@ export function validateSkillMetadata( metadata: Record, filePath?: string ): MetadataValidationResult { - return validateExportMetadata(metadata, { - requiredFields: ['name', 'description'], - optionalDefaults: { - enabled: true, - keywords: [] - }, - filePath - }) + const prefix = filePath != null ? ` in ${filePath}` : '' + const errors: string[] = [] + const warnings: string[] = [] + + if (!('name' in metadata) || metadata['name'] == null) { // Check name field + errors.push(`Missing required field "name"${prefix}`) + } + + if (!('description' in metadata) || metadata['description'] == null) { // Check description field - must exist and not be empty + errors.push(`Missing required field "description"${prefix}`) + } else if (typeof metadata['description'] !== 'string' || metadata['description'].trim().length === 0) { + errors.push(`Required field "description" cannot be empty${prefix}`) + } + + if (metadata['enabled'] == null) { // Optional fields with defaults + warnings.push(`Using default value for optional field "enabled": true${prefix}`) + } + if (metadata['keywords'] == null) warnings.push(`Using default value for optional field "keywords": []${prefix}`) + + return { + valid: errors.length === 0, + errors, + warnings + } } /** diff --git a/cli/src/plugins/plugin-trae-cn-ide/TraeCNIDEOutputPlugin.ts b/cli/src/plugins/plugin-trae-cn-ide/TraeCNIDEOutputPlugin.ts new file mode 100644 index 00000000..e87ffe4d --- /dev/null +++ b/cli/src/plugins/plugin-trae-cn-ide/TraeCNIDEOutputPlugin.ts @@ -0,0 +1,72 @@ +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 GLOBAL_MEMORY_FILE = 'GLOBAL.md' +const GLOBAL_CONFIG_DIR = '.trae-cn' +const USER_RULES_SUBDIR = 'user_rules' + +export class TraeCNIDEOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('TraeCNIDEOutputPlugin', { + globalConfigDir: GLOBAL_CONFIG_DIR, + outputFileName: GLOBAL_MEMORY_FILE, + dependsOn: ['TraeIDEOutputPlugin'] + }) + } + + private getGlobalUserRulesDir(): string { + return this.joinPath(this.getGlobalConfigDir(), USER_RULES_SUBDIR) + } + + async registerProjectOutputDirs(): Promise { + return [] + } + + async registerProjectOutputFiles(): Promise { + return [] + } + + async registerGlobalOutputDirs(): Promise { + return [ + this.createRelativePath(USER_RULES_SUBDIR, this.getGlobalConfigDir(), () => USER_RULES_SUBDIR) + ] + } + + async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { + const {globalMemory} = ctx.collectedInputContext + const results: RelativePath[] = [] + + if (globalMemory != null) results.push(this.createRelativePath(GLOBAL_MEMORY_FILE, this.getGlobalUserRulesDir(), () => USER_RULES_SUBDIR)) + + return results + } + + async canWrite(ctx: OutputWriteContext): Promise { + const {globalMemory} = ctx.collectedInputContext + if (globalMemory != null) return true + this.log.trace({action: 'skip', reason: 'noGlobalMemory'}) + return false + } + + async writeProjectOutputs(): Promise { + return {files: [], dirs: []} + } + + async writeGlobalOutputs(ctx: OutputWriteContext): Promise { + const {globalMemory} = ctx.collectedInputContext + const fileResults: WriteResult[] = [] + const userRulesDir = this.getGlobalUserRulesDir() + + if (globalMemory != null) { + fileResults.push(await this.writeFile(ctx, this.joinPath(userRulesDir, GLOBAL_MEMORY_FILE), globalMemory.content as string, 'globalMemory')) + } + + return {files: fileResults, dirs: []} + } +} diff --git a/cli/src/plugins/plugin-trae-cn-ide/index.ts b/cli/src/plugins/plugin-trae-cn-ide/index.ts new file mode 100644 index 00000000..c064a45f --- /dev/null +++ b/cli/src/plugins/plugin-trae-cn-ide/index.ts @@ -0,0 +1,3 @@ +export { + TraeCNIDEOutputPlugin +} from './TraeCNIDEOutputPlugin' diff --git a/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.test.ts b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.test.ts index 07a71e45..433e9ac3 100644 --- a/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.test.ts +++ b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.test.ts @@ -34,8 +34,8 @@ 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 testTransformFastCommandName(cmd: FastCommandPrompt): string { + return this.transformFastCommandName(cmd, {includeSeriesPrefix: true, seriesSeparator: '-'}) } public async testWriteSteeringFile(ctx: OutputWriteContext, project: Project, child: ProjectChildrenMemoryPrompt): Promise { @@ -58,7 +58,7 @@ class TestableTraeIDEOutputPlugin extends TraeIDEOutputPlugin { } describe('traeIDEOutputPlugin', () => { - describe('buildFastCommandSteeringFileName', () => { + describe('transformFastCommandName', () => { const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) .filter(s => /^[a-z0-9]+$/i.test(s)) @@ -74,7 +74,7 @@ describe('traeIDEOutputPlugin', () => { const plugin = new TestableTraeIDEOutputPlugin() const cmd = createMockFastCommandPrompt(series, commandName) - const result = plugin.testBuildFastCommandSteeringFileName(cmd) + const result = plugin.testTransformFastCommandName(cmd) expect(result).toBe(`${series}-${commandName}.md`) } @@ -91,7 +91,7 @@ describe('traeIDEOutputPlugin', () => { const plugin = new TestableTraeIDEOutputPlugin() const cmd = createMockFastCommandPrompt(void 0, commandName) - const result = plugin.testBuildFastCommandSteeringFileName(cmd) + const result = plugin.testTransformFastCommandName(cmd) expect(result).toBe(`${commandName}.md`) } diff --git a/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts index 4632762f..22c7f35c 100644 --- a/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts +++ b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts @@ -4,17 +4,24 @@ import type { OutputWriteContext, Project, ProjectChildrenMemoryPrompt, + 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 {AbstractOutputPlugin, filterCommandsByProjectConfig} from '@truenine/plugin-output-shared' +import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' +import {AbstractOutputPlugin, filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' +import {FilePathKind} from '@truenine/plugin-shared' const GLOBAL_MEMORY_FILE = 'GLOBAL.md' const GLOBAL_CONFIG_DIR = '.trae' const STEERING_SUBDIR = 'steering' const RULES_SUBDIR = 'rules' +const COMMANDS_SUBDIR = 'commands' +const SKILLS_SUBDIR = 'skills' +const SKILL_FILE_NAME = 'SKILL.md' export class TraeIDEOutputPlugin extends AbstractOutputPlugin { constructor() { @@ -32,27 +39,111 @@ export class TraeIDEOutputPlugin extends AbstractOutputPlugin { 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, + const {fastCommands, skills} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const results: RelativePath[] = [] + + for (const project of projects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null) continue + + results.push(this.createRelativePath( // Register rules dir (existing) + this.joinPath(projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR), + projectDir.basePath, () => RULES_SUBDIR )) + + if (fastCommands != null && fastCommands.length > 0) { // Register commands dir (new: per-project) + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + if (filteredCommands.length > 0) { + results.push(this.createRelativePath( + this.joinPath(projectDir.path, GLOBAL_CONFIG_DIR, COMMANDS_SUBDIR), + projectDir.basePath, + () => COMMANDS_SUBDIR + )) + } + } + + if (skills != null && skills.length > 0) { // Register skills dirs (new: per-project) + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter.name + results.push(this.createRelativePath( + this.joinPath(projectDir.path, GLOBAL_CONFIG_DIR, SKILLS_SUBDIR, skillName), + projectDir.basePath, + () => skillName + )) + } + } + } + + return results } async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { const {projects} = ctx.collectedInputContext.workspace + const {fastCommands, skills} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) 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 - )) + if (project.dirFromWorkspacePath == null) continue + const projectDir = project.dirFromWorkspacePath + + if (project.childMemoryPrompts != null) { // Child memory prompts (existing) + for (const child of project.childMemoryPrompts) { + results.push(this.createRelativePath( + this.joinPath(projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR, this.buildSteeringFileName(child)), + projectDir.basePath, + () => RULES_SUBDIR + )) + } + } + + if (fastCommands != null && fastCommands.length > 0) { // Fast commands (new: per-project) + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + for (const cmd of filteredCommands) { + const fileName = this.transformFastCommandName(cmd, transformOptions) + results.push(this.createRelativePath( + this.joinPath(projectDir.path, GLOBAL_CONFIG_DIR, COMMANDS_SUBDIR, fileName), + projectDir.basePath, + () => COMMANDS_SUBDIR + )) + } + } + + if (skills != null && skills.length > 0) { // Skills (new: per-project) + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter.name + results.push(this.createRelativePath( + this.joinPath(projectDir.path, GLOBAL_CONFIG_DIR, SKILLS_SUBDIR, skillName, SKILL_FILE_NAME), + projectDir.basePath, + () => skillName + )) + + if (skill.childDocs != null) { + for (const childDoc of skill.childDocs) { + const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') + results.push(this.createRelativePath( + this.joinPath(projectDir.path, GLOBAL_CONFIG_DIR, SKILLS_SUBDIR, skillName, outputRelativePath), + projectDir.basePath, + () => skillName + )) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + results.push(this.createRelativePath( + this.joinPath(projectDir.path, GLOBAL_CONFIG_DIR, SKILLS_SUBDIR, skillName, resource.relativePath), + projectDir.basePath, + () => skillName + )) + } + } + } } } @@ -67,36 +158,48 @@ export class TraeIDEOutputPlugin extends AbstractOutputPlugin { } async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { - const {globalMemory, fastCommands} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const steeringDir = this.getGlobalSteeringDir() + const {globalMemory} = ctx.collectedInputContext const results: RelativePath[] = [] - if (globalMemory != null) results.push(this.createRelativePath(GLOBAL_MEMORY_FILE, steeringDir, () => STEERING_SUBDIR)) - - if (fastCommands == null) return results + if (globalMemory != null) results.push(this.createRelativePath(GLOBAL_MEMORY_FILE, this.getGlobalSteeringDir(), () => STEERING_SUBDIR)) - 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 {workspace, globalMemory, fastCommands, skills, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasChildPrompts = workspace.projects.some(p => (p.childMemoryPrompts?.length ?? 0) > 0) + const hasFastCommands = (fastCommands?.length ?? 0) > 0 + const hasSkills = (skills?.length ?? 0) > 0 const hasTraeIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.traeignore') ?? false - if (hasChildPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || hasTraeIgnore) return true + if (hasChildPrompts || globalMemory != null || hasFastCommands || hasSkills || hasTraeIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } async writeProjectOutputs(ctx: OutputWriteContext): Promise { const {projects} = ctx.collectedInputContext.workspace + const {fastCommands, skills} = ctx.collectedInputContext + const projectConfig = this.resolvePromptSourceProjectConfig(ctx) const fileResults: WriteResult[] = [] for (const project of projects) { - if (project.dirFromWorkspacePath == null || project.childMemoryPrompts == null) continue - for (const child of project.childMemoryPrompts) fileResults.push(await this.writeSteeringFile(ctx, project, child)) + if (project.dirFromWorkspacePath == null) continue + const projectDir = project.dirFromWorkspacePath + + if (project.childMemoryPrompts != null) { // Child memory prompts (existing) + for (const child of project.childMemoryPrompts) fileResults.push(await this.writeSteeringFile(ctx, project, child)) + } + + if (fastCommands != null && fastCommands.length > 0) { // Fast commands (new: per-project) + const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + for (const cmd of filteredCommands) fileResults.push(await this.writeProjectFastCommand(ctx, projectDir, cmd)) + } + + if (skills != null && skills.length > 0) { // Skills (new: per-project) + const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) + for (const skill of filteredSkills) fileResults.push(...await this.writeProjectSkill(ctx, projectDir, skill)) + } } const ignoreResults = await this.writeProjectIgnoreFiles(ctx) @@ -106,8 +209,7 @@ export class TraeIDEOutputPlugin extends AbstractOutputPlugin { } async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands} = ctx.collectedInputContext - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) + const {globalMemory} = ctx.collectedInputContext const fileResults: WriteResult[] = [] const steeringDir = this.getGlobalSteeringDir() @@ -115,26 +217,146 @@ export class TraeIDEOutputPlugin extends AbstractOutputPlugin { 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 writeProjectFastCommand(ctx: OutputWriteContext, projectDir: RelativePath, cmd: FastCommandPrompt): Promise { + const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + const fileName = this.transformFastCommandName(cmd, transformOptions) + const commandsDir = path.join(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, COMMANDS_SUBDIR) + const fullPath = path.join(commandsDir, fileName) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.join(projectDir.path, GLOBAL_CONFIG_DIR, COMMANDS_SUBDIR, fileName), + basePath: projectDir.basePath, + getDirectoryName: () => COMMANDS_SUBDIR, + getAbsolutePath: () => fullPath + } + + const content = this.buildMarkdownContentWithRaw(cmd.content, cmd.yamlFrontMatter, cmd.rawFrontMatter) - 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.writeFileWithHandling(ctx, fullPath, content, { + type: 'projectFastCommand', + relativePath }) - return this.writeFile(ctx, fullPath, content, 'fastCommandSteering') + } + + private async writeProjectSkill(ctx: OutputWriteContext, projectDir: RelativePath, skill: SkillPrompt): Promise { + const results: WriteResult[] = [] + const skillName = skill.yamlFrontMatter.name + const skillDir = path.join(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, SKILLS_SUBDIR, skillName) + const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.join(projectDir.path, GLOBAL_CONFIG_DIR, SKILLS_SUBDIR, skillName, SKILL_FILE_NAME), + basePath: projectDir.basePath, + 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: 'projectSkill', path: skillFilePath}) + results.push({path: relativePath, success: true, skipped: false}) + } else { + try { + this.ensureDirectory(skillDir) + this.writeFileSync(skillFilePath, skillContent) + this.log.trace({action: 'write', type: 'projectSkill', path: skillFilePath}) + results.push({path: relativePath, success: true}) + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'projectSkill', path: skillFilePath, error: errMsg}) + results.push({path: relativePath, 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, projectDir)) + } + + if (skill.resources != null) { + for (const resource of skill.resources) results.push(await this.writeSkillResource(ctx, resource, skillDir, skillName, projectDir)) + } + + return results + } + + private async writeSkillChildDoc(ctx: OutputWriteContext, childDoc: {relativePath: string, content: unknown}, skillDir: string, skillName: string, projectDir: RelativePath): Promise { + const outputRelativePath = childDoc.relativePath.replace(/\.mdx$/, '.md') + const childDocPath = path.join(skillDir, outputRelativePath) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.join(projectDir.path, GLOBAL_CONFIG_DIR, SKILLS_SUBDIR, skillName, outputRelativePath), + basePath: projectDir.basePath, + getDirectoryName: () => skillName, + getAbsolutePath: () => childDocPath + } + + const content = childDoc.content as string + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'skillChildDoc', 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: 'skillChildDoc', path: childDocPath}) + return {path: relativePath, success: true} + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'skillChildDoc', 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, projectDir: RelativePath): Promise { + const resourcePath = path.join(skillDir, resource.relativePath) + + const relativePath: RelativePath = { + pathKind: FilePathKind.Relative, + path: path.join(projectDir.path, GLOBAL_CONFIG_DIR, SKILLS_SUBDIR, skillName, resource.relativePath), + basePath: projectDir.basePath, + getDirectoryName: () => skillName, + getAbsolutePath: () => resourcePath + } + + if (ctx.dryRun === true) { + this.log.trace({action: 'dryRun', type: 'skillResource', 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: 'skillResource', path: resourcePath}) + return {path: relativePath, success: true} + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + this.log.error({action: 'write', type: 'skillResource', path: resourcePath, error: errMsg}) + return {path: relativePath, success: false, error: error as Error} + } + } + + protected override buildSkillFrontMatter(skill: SkillPrompt): Record { + const fm: Record = { + description: skill.yamlFrontMatter.description ?? '' + } + + if (skill.yamlFrontMatter.displayName != null) fm['name'] = skill.yamlFrontMatter.displayName + + return fm } private buildSteeringFileName(child: ProjectChildrenMemoryPrompt): string { From 6bc26c89ebc1917a1131475ad5dc348904dbcb83 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 3 Mar 2026 01:39:13 +0800 Subject: [PATCH 15/30] =?UTF-8?q?refactor(types):=20=E7=BB=9F=E4=B8=80=20S?= =?UTF-8?q?eriName=20=E7=B1=BB=E5=9E=8B=E5=B9=B6=E6=89=A9=E5=B1=95=20RuleS?= =?UTF-8?q?cope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将多处重复的 string | string[] | null 类型提取为统一的 SeriName 类型 为 RuleScope 添加 workspace 选项并更新相关校验逻辑 优化 PluginDependencyResolver 的性能,使用 Map 替代数组查找 --- cli/src/pipeline/PluginDependencyResolver.ts | 10 ++++++++-- .../plugin-output-shared/utils/filters.ts | 4 ++-- .../QoderIDEPluginOutputPlugin.ts | 5 +++-- cli/src/plugins/plugin-shared/types/Enums.ts | 4 ++-- .../plugin-shared/types/ExportMetadataTypes.ts | 13 ++++++++++--- .../plugins/plugin-shared/types/InputTypes.ts | 9 +++++---- .../plugins/plugin-shared/types/PromptTypes.ts | 17 +++++++++++++---- 7 files changed, 43 insertions(+), 19 deletions(-) diff --git a/cli/src/pipeline/PluginDependencyResolver.ts b/cli/src/pipeline/PluginDependencyResolver.ts index 43af20da..00d15fb1 100644 --- a/cli/src/pipeline/PluginDependencyResolver.ts +++ b/cli/src/pipeline/PluginDependencyResolver.ts @@ -122,6 +122,12 @@ export function topologicalSort( } const result: Plugin[] = [] // Process queue + const pluginIndexMap = new Map() // Pre-compute plugin indices for O(1) lookup - fixes O(n²) complexity + for (let i = 0; i < plugins.length; i++) { + const plugin = plugins[i] + if (plugin != null) pluginIndexMap.set(plugin.name, i) + } + while (queue.length > 0) { const current = queue.shift()! // Take first element to preserve registration order const plugin = pluginMap.get(current)! @@ -129,8 +135,8 @@ export function topologicalSort( const currentDependents = dependents.get(current) ?? [] // Process dependents in registration order const sortedDependents = currentDependents.sort((a, b) => { // Sort dependents by their original registration order - const indexA = plugins.findIndex(p => p.name === a) - const indexB = plugins.findIndex(p => p.name === b) + const indexA = pluginIndexMap.get(a) ?? -1 + const indexB = pluginIndexMap.get(b) ?? -1 return indexA - indexB }) diff --git a/cli/src/plugins/plugin-output-shared/utils/filters.ts b/cli/src/plugins/plugin-output-shared/utils/filters.ts index abef3c81..131555f5 100644 --- a/cli/src/plugins/plugin-output-shared/utils/filters.ts +++ b/cli/src/plugins/plugin-output-shared/utils/filters.ts @@ -1,4 +1,4 @@ -import type {FastCommandPrompt, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' +import type {FastCommandPrompt, RulePrompt, SeriName, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' import type {ProjectConfig} from '@truenine/plugin-shared/types' import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' @@ -6,7 +6,7 @@ import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' * Interface for items that can be filtered by series name */ export interface SeriesFilterable { - readonly seriName?: string | string[] | null + readonly seriName?: SeriName } /** diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts index 3c3a8e52..18939ee5 100644 --- a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts +++ b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts @@ -4,6 +4,7 @@ import type { OutputWriteContext, ProjectChildrenMemoryPrompt, RulePrompt, + RuleScope, SkillPrompt, WriteResult, WriteResults @@ -419,7 +420,7 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { return buildMarkdownWithFrontMatter(fmData, rule.content) } - protected override normalizeRuleScope(rule: RulePrompt): 'global' | 'project' { - return rule.scope || 'global' + protected override normalizeRuleScope(rule: RulePrompt): RuleScope { + return rule.scope ?? 'global' } } diff --git a/cli/src/plugins/plugin-shared/types/Enums.ts b/cli/src/plugins/plugin-shared/types/Enums.ts index 6b6db7b3..29cfc2d9 100644 --- a/cli/src/plugins/plugin-shared/types/Enums.ts +++ b/cli/src/plugins/plugin-shared/types/Enums.ts @@ -18,9 +18,9 @@ export enum PromptKind { } /** - * Scope for rule application + * Scope for prompt application (rules, skills, commands, subAgents) */ -export type RuleScope = 'project' | 'global' +export type RuleScope = 'project' | 'global' | 'workspace' export enum ClaudeCodeCLISubAgentColors { Red = 'Red', diff --git a/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts b/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts index d17cc006..1c92bbfc 100644 --- a/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts +++ b/cli/src/plugins/plugin-shared/types/ExportMetadataTypes.ts @@ -7,6 +7,7 @@ */ import type {CodingAgentTools, NamingCaseKind, RuleScope} from './Enums' +import type {SeriName} from './PromptTypes' /** * Base export metadata interface @@ -25,6 +26,8 @@ export interface SkillExportMetadata extends BaseExportMetadata { readonly author?: string readonly version?: string readonly allowTools?: readonly (CodingAgentTools | string)[] + readonly seriName?: SeriName + readonly scope?: RuleScope } export interface FastCommandExportMetadata extends BaseExportMetadata { @@ -32,13 +35,15 @@ export interface FastCommandExportMetadata extends BaseExportMetadata { readonly argumentHint?: string readonly allowTools?: readonly (CodingAgentTools | string)[] readonly globalOnly?: boolean + readonly seriName?: SeriName + readonly scope?: RuleScope } export interface RuleExportMetadata extends BaseExportMetadata { readonly globs: readonly string[] readonly description: string readonly scope?: RuleScope - readonly seriName?: string + readonly seriName?: SeriName } export interface SubAgentExportMetadata extends BaseExportMetadata { @@ -49,6 +54,8 @@ export interface SubAgentExportMetadata extends BaseExportMetadata { readonly color?: string readonly argumentHint?: string readonly allowTools?: readonly (CodingAgentTools | string)[] + readonly seriName?: SeriName + readonly scope?: RuleScope } /** @@ -199,11 +206,11 @@ export function validateRuleMetadata( 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 && scope !== 'project' && scope !== 'global' && scope !== 'workspace') errors.push(`Field "scope" must be "project", "global" or "workspace"${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}`) + if (seriName != null && typeof seriName !== 'string' && !Array.isArray(seriName)) errors.push(`Field "seriName" must be a string or string array${prefix}`) return {valid: errors.length === 0, errors, warnings} } diff --git a/cli/src/plugins/plugin-shared/types/InputTypes.ts b/cli/src/plugins/plugin-shared/types/InputTypes.ts index 31fd19d8..3843f712 100644 --- a/cli/src/plugins/plugin-shared/types/InputTypes.ts +++ b/cli/src/plugins/plugin-shared/types/InputTypes.ts @@ -14,6 +14,7 @@ import type { ProjectRootMemoryPrompt, Prompt, RuleYAMLFrontMatter, + SeriName, SkillYAMLFrontMatter, SubAgentYAMLFrontMatter } from './PromptTypes' @@ -88,7 +89,7 @@ export interface RulePrompt extends Prompt extends YAMLFrontMatter { readonly description: string } @@ -58,11 +64,13 @@ export interface SubAgentYAMLFrontMatter extends ToolAwareYAMLFrontMatter { readonly name: string readonly model?: string readonly color?: ClaudeCodeCLISubAgentColors | string - readonly seriName?: string | string[] | null + readonly seriName?: SeriName + readonly scope?: RuleScope } export interface FastCommandYAMLFrontMatter extends ToolAwareYAMLFrontMatter { - readonly seriName?: string | string[] | null + readonly seriName?: SeriName + readonly scope?: RuleScope } // description, argumentHint, allowTools inherited from ToolAwareYAMLFrontMatter /** @@ -78,7 +86,8 @@ export interface SkillYAMLFrontMatter extends SkillsYAMLFrontMatter { readonly displayName?: string readonly author?: string readonly version?: string - readonly seriName?: string | string[] | null + readonly seriName?: SeriName + readonly scope?: RuleScope } /** @@ -131,7 +140,7 @@ export interface KiroPowerYAMLFrontMatter extends SkillsYAMLFrontMatter { export interface RuleYAMLFrontMatter extends CommonYAMLFrontMatter { readonly globs: readonly string[] readonly scope?: RuleScope - readonly seriName?: string | string[] | null + readonly seriName?: SeriName } /** From 0b31dfa48982774b795505084a286d85158d9a28 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 3 Mar 2026 01:42:29 +0800 Subject: [PATCH 16/30] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7=E8=87=B32026.10303.10139?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 962bc478..bd066cab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync", - "version": "2026.10302.10037", + "version": "2026.10303.10139", "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 052cdf68e32f4d2b67211ad5f626e8f5c8b6d798 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 3 Mar 2026 01:43:57 +0800 Subject: [PATCH 17/30] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E6=89=80?= =?UTF-8?q?=E6=9C=89=E5=8C=85=E7=9A=84=E7=89=88=E6=9C=AC=E5=8F=B7=E8=87=B3?= =?UTF-8?q?2026.10303.10139?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- 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/input-plugins/package.json | 2 +- libraries/logger/package.json | 2 +- libraries/md-compiler/package.json | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f3943b5..2fc2af2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10302.10037" +version = "2026.10303.10139" dependencies = [ "dirs", "proptest", diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index ddf9caad..847dd6b8 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.10302.10037", + "version": "2026.10303.10139", "os": [ "darwin" ], diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index 0308e7fc..37f50ba1 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.10302.10037", + "version": "2026.10303.10139", "os": [ "darwin" ], diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index 9b7bb142..d1020a09 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.10302.10037", + "version": "2026.10303.10139", "os": [ "linux" ], diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 3b7a52b9..5f4d60be 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.10302.10037", + "version": "2026.10303.10139", "os": [ "linux" ], diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index f296a87c..d95b1995 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.10302.10037", + "version": "2026.10303.10139", "os": [ "win32" ], diff --git a/cli/package.json b/cli/package.json index 0e1d349f..256fa81f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10302.10037", + "version": "2026.10303.10139", "description": "TrueNine Memory Synchronization CLI", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/doc/package.json b/doc/package.json index d8dfad86..88b003a0 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-docs", - "version": "2026.10302.10037", + "version": "2026.10303.10139", "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 4c7d4fbf..66261eaf 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10302.10037", + "version": "2026.10303.10139", "private": true, "engines": { "node": ">=25.2.1", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 6b792606..3b244355 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memory-sync-gui" -version = "2026.10302.10037" +version = "2026.10303.10139" 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 a28261d8..7e4b2af4 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.10302.10037", + "version": "2026.10303.10139", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/libraries/config/package.json b/libraries/config/package.json index 069c7917..ef37b961 100644 --- a/libraries/config/package.json +++ b/libraries/config/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/config", "type": "module", - "version": "2026.10302.10037", + "version": "2026.10303.10139", "private": true, "description": "Rust-powered configuration loader for Node.js", "license": "AGPL-3.0-only", diff --git a/libraries/input-plugins/package.json b/libraries/input-plugins/package.json index c2c4b3a8..6745d379 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.10302.10037", + "version": "2026.10303.10139", "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 9bb361a9..d063d1d5 100644 --- a/libraries/logger/package.json +++ b/libraries/logger/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/logger", "type": "module", - "version": "2026.10302.10037", + "version": "2026.10303.10139", "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 4b8185dc..4ae6b131 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.10302.10037", + "version": "2026.10303.10139", "private": true, "description": "Rust-powered MDX→Markdown compiler for Node.js with pure-TS fallback", "license": "AGPL-3.0-only", From f250061aa8125f845b3ab048742e46406e1130d1 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 3 Mar 2026 02:34:25 +0800 Subject: [PATCH 18/30] =?UTF-8?q?refactor(plugins):=20=E5=B0=86=E6=89=80?= =?UTF-8?q?=E6=9C=89=E8=BE=93=E5=87=BA=E6=96=87=E4=BB=B6=E7=A7=BB=E8=87=B3?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=BA=A7=E5=88=AB=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构所有 CLI 插件,将命令、代理、技能等输出文件从全局配置目录(.claude/.factory/.config/opencode)移至项目级别目录。同时更新相关测试用例,确保所有功能在项目级别正常工作。 - 移除全局输出目录注册功能 - 修改规则处理逻辑,所有规则(包括原全局规则)现在都写入项目目录 - 更新测试用例以验证项目级别的输出文件注册和写入 - 保持对.mdx文件转换为.md的支持 --- ...ClaudeCodeCLIOutputPlugin.property.test.ts | 9 +- .../ClaudeCodeCLIOutputPlugin.test.ts | 216 +++++++++-------- .../ClaudeCodeCLIOutputPlugin.ts | 37 +-- .../DroidCLIOutputPlugin.test.ts | 141 ++++++++--- .../OpencodeCLIOutputPlugin.test.ts | 149 +++++++++--- .../OpencodeCLIOutputPlugin.ts | 66 ++++-- .../BaseCLIOutputPlugin.ts | 219 ++++++++---------- 7 files changed, 496 insertions(+), 341 deletions(-) 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 8e1c8e80..f1f2536c 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 @@ -122,18 +122,13 @@ describe('claudeCodeCLIOutputPlugin property tests', () => { }) describe('write output format verification', () => { - it('should write global rule files with correct format to ~/.claude/rules/', async () => { + it('should NOT write global rule files (all rules go to project level)', 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).toMatch(new RegExp(`-\\s+['"]?${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?`)) // Accept quoted or unquoted formats + expect(fs.existsSync(filePath)).toBe(false) }), {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 eda325c0..064db7d3 100644 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts @@ -99,16 +99,9 @@ describe('claudeCodeCLIOutputPlugin', () => { }) describe('registerGlobalOutputDirs', () => { - it('should register commands, agents, and skills subdirectories in .claude', async () => { + it('should return empty array since all outputs go to project level', 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)) + expect(dirs).toHaveLength(0) }) }) @@ -142,7 +135,7 @@ describe('claudeCodeCLIOutputPlugin', () => { } 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 + const dirPaths = dirs.map(d => d.path) expect(dirPaths.some(p => p.includes(path.join('.claude', 'commands')))).toBe(true) expect(dirPaths.some(p => p.includes(path.join('.claude', 'agents')))).toBe(true) @@ -158,7 +151,7 @@ describe('claudeCodeCLIOutputPlugin', () => { expect(outputFile?.basePath).toBe(path.join(tempDir, '.claude')) }) - it('should register fast commands in commands subdirectory', async () => { + it('should not register fast commands globally (only project level)', async () => { const mockCmd: FastCommandPrompt = { type: PromptKind.FastCommand, commandName: 'test-cmd', @@ -181,12 +174,10 @@ describe('claudeCodeCLIOutputPlugin', () => { 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')) + expect(cmdFile).toBeUndefined() }) - it('should register sub agents in agents subdirectory', async () => { + it('should not register sub agents globally (only project level)', async () => { const mockAgent: SubAgentPrompt = { type: PromptKind.SubAgent, content: 'content', @@ -208,39 +199,10 @@ describe('claudeCodeCLIOutputPlugin', () => { 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') + expect(agentFile).toBeUndefined() }) - it('should register skills in skills subdirectory', async () => { + it('should not register skills globally (only project level)', async () => { const mockSkill: SkillPrompt = { type: PromptKind.Skill, content: 'content', @@ -262,14 +224,12 @@ describe('claudeCodeCLIOutputPlugin', () => { 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')) + expect(skillFile).toBeUndefined() }) }) describe('registerProjectOutputFiles', () => { - it('should only register project CLAUDE.md files', async () => { + it('should register project CLAUDE.md and project-level commands/agents/skills', async () => { const mockProject: Project = { name: 'test-project', dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), @@ -286,6 +246,37 @@ describe('claudeCodeCLIOutputPlugin', () => { sourceFiles: [] } + 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 mockAgent: SubAgentPrompt = { + type: PromptKind.SubAgent, + content: 'content', + filePathKind: FilePathKind.Relative, + dir: createMockRelativePath('code-review.cn.mdx', tempDir), + markdownContents: [], + length: 0, + yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'desc'} + } + + 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 ctxWithProject = { ...mockContext, collectedInputContext: { @@ -293,20 +284,35 @@ describe('claudeCodeCLIOutputPlugin', () => { workspace: { ...mockContext.collectedInputContext.workspace, projects: [mockProject] - } + }, + fastCommands: [mockCmd], + subAgents: [mockAgent], + skills: [mockSkill] } } 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) + const claudeFile = files.find(f => f.path.includes('CLAUDE.md')) // Check CLAUDE.md + expect(claudeFile).toBeDefined() + + const cmdFile = files.find(f => f.path.includes('test-cmd.md')) // Check command + expect(cmdFile).toBeDefined() + expect(cmdFile?.path).toContain('commands') + + const agentFile = files.find(f => f.path.includes('code-review.cn.md')) // Check agent (should have .md not .mdx) + expect(agentFile).toBeDefined() + expect(agentFile?.path).toContain('agents') + expect(agentFile?.path).not.toContain('.mdx') + + const skillFile = files.find(f => f.path.includes('SKILL.md')) // Check skill + expect(skillFile).toBeDefined() + expect(skillFile?.path).toContain('skills') }) }) describe('writeGlobalOutputs', () => { - it('should write sub agent file with .md extension when source has .mdx', async () => { + it('should not write sub agents globally (only project level)', async () => { const mockAgent: SubAgentPrompt = { type: PromptKind.SubAgent, content: '# Code Review Agent', @@ -328,13 +334,10 @@ describe('claudeCodeCLIOutputPlugin', () => { 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) + expect(agentResult).toBeUndefined() - 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) + const writtenPath = path.join(tempDir, '.claude', 'agents', 'reviewer.cn.md') // Verify file was not written globally + expect(fs.existsSync(writtenPath)).toBe(false) }) }) @@ -389,38 +392,43 @@ describe('claudeCodeCLIOutputPlugin', () => { }) describe('rules registration', () => { - it('should register rules subdir in global output dirs when global rules exist', async () => { + it('should not register rules globally (only project level)', 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 () => { + it('should register rules at project level', 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, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} + collectedInputContext: { + ...mockContext.collectedInputContext, + workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, + 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')) + const dirs = await plugin.registerProjectOutputDirs(ctx) + expect(dirs.map(d => d.path)).toContain(path.join('proj', '.claude', 'rules')) }) - it('should not register project rules as global files', async () => { + it('should not register global rule files (only project level)', async () => { const ctx = { ...mockContext, - collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'project'})]} + collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} } const files = await plugin.registerGlobalOutputFiles(ctx) - expect(files.find(f => f.path.includes('rule-'))).toBeUndefined() + const ruleFile = files.find(f => f.path === 'rule-01-ts.md') + expect(ruleFile).toBeUndefined() }) }) @@ -439,7 +447,7 @@ describe('claudeCodeCLIOutputPlugin', () => { }) describe('writeGlobalOutputs with rules', () => { - it('should write global rule file to ~/.claude/rules/', async () => { + it('should not write global rule files (only project level)', async () => { const ctx = { ...mockContext, collectedInputContext: { @@ -449,33 +457,44 @@ describe('claudeCodeCLIOutputPlugin', () => { } 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) + expect(ruleResult).toBeUndefined() 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') + expect(fs.existsSync(filePath)).toBe(false) }) + }) - it('should write rule without frontmatter when globs is empty', async () => { + describe('writeProjectOutputs with rules', () => { + it('should write all rules to {project}/.claude/rules/ (including previously global scoped)', 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, - rules: [createMockRulePrompt({series: '01', ruleName: 'general', globs: [], scope: 'global', content: '# Always apply'})] + workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, + rules: [ + createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global', content: '# TS rule'}), + createMockRulePrompt({series: '02', ruleName: 'api', globs: ['src/api/**'], scope: 'project', content: '# API rules'}) + ] } } - 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('---') + const results = await plugin.writeProjectOutputs(ctx as any) + expect(results.files.some(f => f.path.path === 'rule-01-ts.md' && f.success)).toBe(true) + expect(results.files.some(f => f.path.path === 'rule-02-api.md' && f.success)).toBe(true) + + const filePath1 = path.join(tempDir, 'proj', '.claude', 'rules', 'rule-01-ts.md') + const filePath2 = path.join(tempDir, 'proj', '.claude', 'rules', 'rule-02-api.md') + expect(fs.existsSync(filePath1)).toBe(true) + expect(fs.existsSync(filePath2)).toBe(true) }) - }) - describe('writeProjectOutputs with rules', () => { - it('should write project rule file to {project}/.claude/rules/', async () => { + it('should write rule without frontmatter when globs is empty', async () => { const mockProject: Project = { name: 'proj', dirFromWorkspacePath: createMockRelativePath('proj', tempDir), @@ -488,17 +507,14 @@ describe('claudeCodeCLIOutputPlugin', () => { collectedInputContext: { ...mockContext.collectedInputContext, workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, - rules: [createMockRulePrompt({series: '02', ruleName: 'api', globs: ['src/api/**'], scope: 'project', content: '# API rules'})] + rules: [createMockRulePrompt({series: '01', ruleName: 'general', globs: [], scope: 'global', content: '# Always apply'})] } } - 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) + await plugin.writeProjectOutputs(ctx as any) + const filePath = path.join(tempDir, 'proj', '.claude', 'rules', 'rule-01-general.md') const content = fs.readFileSync(filePath, 'utf8') - expect(content).toContain('paths:') - expect(content).toContain('# API rules') + expect(content).toBe('# Always apply') + expect(content).not.toContain('---') }) }) }) diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts index baaab994..7d6b4d99 100644 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts @@ -39,20 +39,12 @@ export class ClaudeCodeCLIOutputPlugin extends BaseCLIOutputPlugin { 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 registerGlobalOutputDirs(_ctx: OutputPluginContext): Promise { + return [] } 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 + return super.registerGlobalOutputFiles(ctx) } override async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { @@ -62,10 +54,7 @@ export class ClaudeCodeCLIOutputPlugin extends BaseCLIOutputPlugin { 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 - ), + filterRulesByProjectConfig(rules, project.projectConfig), project.projectConfig ) if (projectRules.length === 0) continue @@ -82,10 +71,7 @@ export class ClaudeCodeCLIOutputPlugin extends BaseCLIOutputPlugin { 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 - ), + filterRulesByProjectConfig(rules, project.projectConfig), project.projectConfig ) for (const rule of projectRules) { @@ -102,13 +88,7 @@ export class ClaudeCodeCLIOutputPlugin extends BaseCLIOutputPlugin { } 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} + return super.writeGlobalOutputs(ctx) } override async writeProjectOutputs(ctx: OutputWriteContext): Promise { @@ -119,10 +99,7 @@ export class ClaudeCodeCLIOutputPlugin extends BaseCLIOutputPlugin { 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 - ), + filterRulesByProjectConfig(rules, project.projectConfig), project.projectConfig ) if (projectRules.length === 0) continue diff --git a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts index 0cad3c85..8500e9cd 100644 --- a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts +++ b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts @@ -74,16 +74,9 @@ describe('droidCLIOutputPlugin', () => { }) describe('registerGlobalOutputDirs', () => { - it('should register commands, agents, and skills subdirectories in .factory', async () => { + it('should return empty array since all outputs go to project level', 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)) + expect(dirs).toHaveLength(0) }) }) @@ -133,7 +126,7 @@ describe('droidCLIOutputPlugin', () => { expect(outputFile?.basePath).toBe(path.join(tempDir, '.factory')) }) - it('should register fast commands in commands subdirectory', async () => { + it('should NOT register fast commands globally (only project level)', async () => { const mockCmd: FastCommandPrompt = { type: PromptKind.FastCommand, commandName: 'test-cmd', @@ -156,12 +149,10 @@ describe('droidCLIOutputPlugin', () => { 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')) + expect(cmdFile).toBeUndefined() }) - it('should register sub agents in agents subdirectory', async () => { + it('should NOT register sub agents globally (only project level)', async () => { const mockAgent: SubAgentPrompt = { type: PromptKind.SubAgent, content: 'content', @@ -183,12 +174,10 @@ describe('droidCLIOutputPlugin', () => { 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')) + expect(agentFile).toBeUndefined() }) - it('should strip .mdx suffix from sub agent path and use .md', async () => { + it('should NOT register sub agents globally (mdx test)', async () => { const mockAgent: SubAgentPrompt = { type: PromptKind.SubAgent, content: 'agent content', @@ -210,12 +199,10 @@ describe('droidCLIOutputPlugin', () => { 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') + expect(agentFile).toBeUndefined() }) - it('should register skills in skills subdirectory', async () => { + it('should NOT register skills globally (only project level)', async () => { const mockSkill: SkillPrompt = { type: PromptKind.Skill, content: 'content', @@ -237,20 +224,58 @@ describe('droidCLIOutputPlugin', () => { 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')) + expect(skillFile).toBeUndefined() }) }) describe('registerProjectOutputFiles', () => { - it('should return empty array', async () => { + it('should register project-level commands, agents, and skills', 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 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 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 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 ctxWithProject = { ...mockContext, collectedInputContext: { @@ -258,12 +283,72 @@ describe('droidCLIOutputPlugin', () => { workspace: { ...mockContext.collectedInputContext.workspace, projects: [mockProject] - } + }, + fastCommands: [mockCmd], + subAgents: [mockAgent], + skills: [mockSkill] } } const files = await plugin.registerProjectOutputFiles(ctxWithProject) - expect(files).toEqual([]) + + const cmdFile = files.find(f => f.path.includes('test-cmd.md')) // Check command + expect(cmdFile).toBeDefined() + expect(cmdFile?.path).toContain('commands') + + const agentFile = files.find(f => f.path.includes('test-agent.md')) // Check agent + expect(agentFile).toBeDefined() + expect(agentFile?.path).toContain('agents') + + const skillFile = files.find(f => f.path.includes('SKILL.md')) // Check skill + expect(skillFile).toBeDefined() + expect(skillFile?.path).toContain('skills') + }) + + it('should strip .mdx suffix from sub agent path at project level', 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 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 ctxWithProject = { + ...mockContext, + collectedInputContext: { + ...mockContext.collectedInputContext, + workspace: { + ...mockContext.collectedInputContext.workspace, + projects: [mockProject] + }, + subAgents: [mockAgent] + } + } + + const files = await plugin.registerProjectOutputFiles(ctxWithProject) + 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') }) }) }) diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts index 21b978dc..f779d2ce 100644 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts @@ -105,16 +105,9 @@ describe('opencodeCLIOutputPlugin', () => { }) describe('registerGlobalOutputDirs', () => { - it('should register commands, agents, and skills subdirectories in .config/opencode', async () => { + it('should return empty array since all outputs go to project level', 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)) + expect(dirs).toHaveLength(0) }) }) @@ -164,7 +157,7 @@ describe('opencodeCLIOutputPlugin', () => { expect(outputFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) }) - it('should register fast commands in commands subdirectory', async () => { + it('should NOT register fast commands globally (only project level)', async () => { const mockCmd: FastCommandPrompt = { type: PromptKind.FastCommand, commandName: 'test-cmd', @@ -187,12 +180,10 @@ describe('opencodeCLIOutputPlugin', () => { 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')) + expect(cmdFile).toBeUndefined() }) - it('should register agents in agents subdirectory', async () => { + it('should NOT register agents globally (only project level)', async () => { const mockAgent: SubAgentPrompt = { type: PromptKind.SubAgent, content: 'content', @@ -214,12 +205,10 @@ describe('opencodeCLIOutputPlugin', () => { 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')) + expect(agentFile).toBeUndefined() }) - it('should strip .mdx suffix from agent path and use .md', async () => { + it('should NOT register agents globally (mdx test)', async () => { const mockAgent: SubAgentPrompt = { type: PromptKind.SubAgent, content: 'agent content', @@ -241,12 +230,10 @@ describe('opencodeCLIOutputPlugin', () => { 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') + expect(agentFile).toBeUndefined() }) - it('should register skills in skills subdirectory', async () => { + it('should NOT register skills globally (only project level)', async () => { const mockSkill: SkillPrompt = { type: PromptKind.Skill, content: 'content', @@ -268,20 +255,58 @@ describe('opencodeCLIOutputPlugin', () => { 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')) + expect(skillFile).toBeUndefined() }) }) describe('registerProjectOutputFiles', () => { - it('should return empty array (no project-level AGENTS.md)', async () => { + it('should register project-level commands, agents, and skills', 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 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 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 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 ctxWithProject = { ...mockContext, collectedInputContext: { @@ -289,17 +314,31 @@ describe('opencodeCLIOutputPlugin', () => { workspace: { ...mockContext.collectedInputContext.workspace, projects: [mockProject] - } + }, + fastCommands: [mockCmd], + subAgents: [mockAgent], + skills: [mockSkill] } } const files = await plugin.registerProjectOutputFiles(ctxWithProject) - expect(files).toEqual([]) + + const cmdFile = files.find(f => f.path.includes('test-cmd.md')) // Check command + expect(cmdFile).toBeDefined() + expect(cmdFile?.path).toContain('commands') + + const agentFile = files.find(f => f.path.includes('test-agent.md')) // Check agent + expect(agentFile).toBeDefined() + expect(agentFile?.path).toContain('agents') + + const skillFile = files.find(f => f.path.includes('SKILL.md')) // Check skill + expect(skillFile).toBeDefined() + expect(skillFile?.path).toContain('skills') }) }) describe('skill name normalization', () => { - it('should normalize skill names to opencode format', async () => { + it('should normalize skill names to opencode format at project level', async () => { const testCases = [ {input: 'My Skill', expected: 'my-skill'}, {input: 'Skill__Name', expected: 'skill-name'}, @@ -320,15 +359,34 @@ describe('opencodeCLIOutputPlugin', () => { yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: input, description: 'desc'} } + 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 ctxWithSkill = { ...mockContext, collectedInputContext: { ...mockContext.collectedInputContext, + workspace: { + ...mockContext.collectedInputContext.workspace, + projects: [mockProject] + }, skills: [mockSkill] } } - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) + const files = await plugin.registerProjectOutputFiles(ctxWithSkill) const skillFile = files.find(f => f.path.includes('SKILL.md')) expect(skillFile).toBeDefined() @@ -586,8 +644,23 @@ describe('opencodeCLIOutputPlugin', () => { }) }) - describe('writeGlobalOutputs sub-agent mdx regression', () => { - it('should write sub agent file with .md extension when source has .mdx', async () => { + describe('writeProjectOutputs sub-agent mdx regression', () => { + it('should write sub agent file with .md extension when source has .mdx at project level', 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 mockAgent: SubAgentPrompt = { type: PromptKind.SubAgent, content: '# Code Review Agent', @@ -602,20 +675,24 @@ describe('opencodeCLIOutputPlugin', () => { ...mockContext, collectedInputContext: { ...mockContext.collectedInputContext, + workspace: { + ...mockContext.collectedInputContext.workspace, + projects: [mockProject] + }, subAgents: [mockAgent] } } - const results = await plugin.writeGlobalOutputs(writeCtx) + const results = await plugin.writeProjectOutputs(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') + const writtenPath = path.join(tempDir, 'project-a', '.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) + expect(fs.existsSync(path.join(tempDir, 'project-a', '.config/opencode', 'agents', 'reviewer.cn.mdx'))).toBe(false) + expect(fs.existsSync(path.join(tempDir, 'project-a', '.config/opencode', 'agents', 'reviewer.cn.mdx.md'))).toBe(false) }) }) diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts index d6737ef2..15845528 100644 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts @@ -126,6 +126,51 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { }) } + override async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const baseResults = await super.registerProjectOutputFiles(ctx) + + const {rules} = ctx.collectedInputContext // Add project rules + if (rules != null && rules.length > 0) { + for (const project of ctx.collectedInputContext.workspace.projects) { + if (project.dirFromWorkspacePath == null) continue + const projectRules = applySubSeriesGlobPrefix( + filterRulesByProjectConfig(rules, project.projectConfig), + project.projectConfig + ) + for (const rule of projectRules) { + const filePath = path.join(project.dirFromWorkspacePath.path, PROJECT_RULES_DIR, RULES_SUBDIR, this.buildRuleFileName(rule)) + baseResults.push(this.createRelativePath(filePath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) + } + } + } + + return baseResults.map(result => { // Normalize skill directory names in paths for opencode format + 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(result.basePath, newPath.replaceAll('/', path.sep)) + } + }) + } + override async writeGlobalOutputs(ctx: OutputWriteContext): Promise { const baseResults = await super.writeGlobalOutputs(ctx) const files = [...baseResults.files] @@ -384,27 +429,6 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { 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) diff --git a/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts b/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts index cfe8db8e..5f5346f5 100644 --- a/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts @@ -53,17 +53,7 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { } 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 + return [] } async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { @@ -103,79 +93,79 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { 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 (project.dirFromWorkspacePath == null) continue - 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)) - } - } + const {projectConfig} = project + const basePath = path.join(project.dirFromWorkspacePath.path, this.globalConfigDir) + const transformOptions = {includeSeriesPrefix: true} as const - 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.supportsFastCommands && ctx.collectedInputContext.fastCommands != null) { + const filteredCommands = filterCommandsByProjectConfig(ctx.collectedInputContext.fastCommands, projectConfig) + for (const cmd of filteredCommands) { + const fileName = this.transformFastCommandName(cmd, transformOptions) + results.push(this.createRelativePath(path.join(basePath, this.commandsSubDir, fileName), project.dirFromWorkspacePath.basePath, () => this.commandsSubDir)) + } } - } - - 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 (this.supportsSubAgents && ctx.collectedInputContext.subAgents != null) { + const filteredSubAgents = filterSubAgentsByProjectConfig(ctx.collectedInputContext.subAgents, projectConfig) + for (const agent of filteredSubAgents) { + const fileName = agent.dir.path.replace(/\.mdx$/, '.md') + results.push(this.createRelativePath(path.join(basePath, this.agentsSubDir, fileName), project.dirFromWorkspacePath.basePath, () => this.agentsSubDir)) } } - if (skill.resources != null) { - for (const resource of skill.resources) { - const resourcePath = path.join(skillDir, resource.relativePath) - results.push(this.createRelativePath(resourcePath, globalDir, () => skillName)) + if (this.supportsSkills && ctx.collectedInputContext.skills != null) { + const filteredSkills = filterSkillsByProjectConfig(ctx.collectedInputContext.skills, projectConfig) + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const skillDir = path.join(basePath, this.skillsSubDir, skillName) + + results.push(this.createRelativePath(path.join(skillDir, 'SKILL.md'), project.dirFromWorkspacePath.basePath, () => 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, project.dirFromWorkspacePath.basePath, () => skillName)) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + const resourcePath = path.join(skillDir, resource.relativePath) + results.push(this.createRelativePath(resourcePath, project.dirFromWorkspacePath.basePath, () => skillName)) + } + } } } } + return results } + async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { + const {globalMemory} = ctx.collectedInputContext + if (globalMemory == null) return [] + + const globalDir = this.getGlobalConfigDir() + return [ + this.createRelativePath(this.outputFileName, globalDir, () => this.globalConfigDir) + ] + } + 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 + const hasProjectLevelCommands = this.supportsFastCommands && (fastCommands?.length ?? 0) > 0 && workspace.projects.length > 0 + const hasProjectLevelSubAgents = this.supportsSubAgents && (subAgents?.length ?? 0) > 0 && workspace.projects.length > 0 + const hasProjectLevelSkills = this.supportsSkills && (skills?.length ?? 0) > 0 && workspace.projects.length > 0 - if (hasProjectOutputs || hasGlobalMemory || hasFastCommands || hasSubAgents || hasSkills) return true + if (hasProjectOutputs || hasGlobalMemory || hasProjectLevelCommands || hasProjectLevelSubAgents || hasProjectLevelSkills) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false @@ -203,6 +193,33 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { fileResults.push(childResult) } } + + const {projectConfig} = project + const basePath = path.join(projectDir.basePath, projectDir.path, this.globalConfigDir) + + if (this.supportsFastCommands && ctx.collectedInputContext.fastCommands != null) { + const filteredCommands = filterCommandsByProjectConfig(ctx.collectedInputContext.fastCommands, projectConfig) + for (const cmd of filteredCommands) { + const cmdResults = await this.writeFastCommand(ctx, basePath, cmd) + fileResults.push(...cmdResults) + } + } + + if (this.supportsSubAgents && ctx.collectedInputContext.subAgents != null) { + const filteredSubAgents = filterSubAgentsByProjectConfig(ctx.collectedInputContext.subAgents, projectConfig) + for (const agent of filteredSubAgents) { + const agentResults = await this.writeSubAgent(ctx, basePath, agent) + fileResults.push(...agentResults) + } + } + + if (this.supportsSkills && ctx.collectedInputContext.skills != null) { + const filteredSkills = filterSkillsByProjectConfig(ctx.collectedInputContext.skills, projectConfig) + for (const skill of filteredSkills) { + const skillResults = await this.writeSkill(ctx, basePath, skill) + fileResults.push(...skillResults) + } + } } return {files: fileResults, dirs: dirResults} @@ -213,68 +230,32 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { 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} + if (globalMemory == 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) + 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}) } - } - - 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) + 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.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} } From 47550738792d7da4b57b246a7ca033b4d51ce330 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 3 Mar 2026 06:02:21 +0800 Subject: [PATCH 19/30] =?UTF-8?q?refactor(cli):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E5=B0=86fastCommand=E9=87=8D=E5=91=BD=E5=90=8D=E4=B8=BAcommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将FastCommand相关命名统一改为Command,包括类型、变量、文件名等 - 删除不再使用的antigravity插件及相关测试文件 - 移除默认配置生成逻辑,改为必须提供有效配置文件 - 优化子代理输入插件的日志输出 - 清理无用测试文件和测试代码 - 更新aindex配置类型,支持自定义模块路径 --- cli/src/ConfigLoader.test.ts | 305 ------- cli/src/ConfigLoader.ts | 2 +- cli/src/PluginPipeline.property.test.ts | 296 ------ cli/src/PluginPipeline.test.ts | 819 ----------------- cli/src/commands/Command.test.ts | 213 ----- cli/src/commands/ConfigShowCommand.test.ts | 174 ---- cli/src/commands/ExecuteCommand.test.ts | 605 ------------- cli/src/commands/HelpCommand.test.ts | 84 -- cli/src/commands/JsonOutput.property.test.ts | 170 ---- cli/src/commands/JsonOutputCommand.test.ts | 261 ------ cli/src/commands/PluginsCommand.test.ts | 250 ----- cli/src/compiler-integration.test.ts | 446 --------- cli/src/config.test.ts | 10 - cli/src/config.ts | 26 +- cli/src/config/ConfigService.test.ts | 336 ------- cli/src/config/ConfigService.ts | 48 +- cli/src/config/index.ts | 1 - cli/src/config/pathResolver.test.ts | 348 ------- cli/src/config/schema.test.ts | 293 ------ cli/src/config/schema.ts | 35 +- cli/src/config/types.ts | 5 +- cli/src/inputs/effect-orphan-cleanup.ts | 2 +- cli/src/inputs/index.ts | 6 +- ...input-fast-command.ts => input-command.ts} | 46 +- cli/src/inputs/input-subagent.ts | 18 +- cli/src/pipeline/ContextMerger.ts | 4 +- cli/src/plugin.config.ts | 6 +- .../plugins/desk-paths/index.property.test.ts | 174 ---- .../GenericSkillsOutputPlugin.test.ts | 450 --------- .../AntigravityOutputPlugin.test.ts | 343 ------- .../AntigravityOutputPlugin.ts | 216 ----- cli/src/plugins/plugin-antigravity/index.ts | 3 - ...eCodeCLIOutputPlugin.projectConfig.test.ts | 214 ----- ...ClaudeCodeCLIOutputPlugin.property.test.ts | 156 ---- .../ClaudeCodeCLIOutputPlugin.test.ts | 520 ----------- .../ClaudeCodeCLIOutputPlugin.ts | 10 +- .../CursorOutputPlugin.projectConfig.test.ts | 214 ----- .../plugin-cursor/CursorOutputPlugin.test.ts | 833 ----------------- .../plugin-cursor/CursorOutputPlugin.ts | 32 +- .../DroidCLIOutputPlugin.test.ts | 354 -------- .../plugin-droid-cli/DroidCLIOutputPlugin.ts | 2 +- .../GeminiCLIOutputPlugin.ts | 2 +- .../GitExcludeOutputPlugin.test.ts | 265 ------ .../AbstractInputPlugin.test.ts | 357 -------- .../LocalizedPromptReader.ts | 152 ++-- ...BrainsAIAssistantCodexOutputPlugin.test.ts | 391 -------- .../JetBrainsAIAssistantCodexOutputPlugin.ts | 18 +- .../CodexCLIOutputPlugin.ts | 20 +- ...ncodeCLIOutputPlugin.projectConfig.test.ts | 231 ----- .../OpencodeCLIOutputPlugin.property.test.ts | 158 ---- .../OpencodeCLIOutputPlugin.test.ts | 854 ------------------ .../OpencodeCLIOutputPlugin.ts | 204 ++++- .../AbstractOutputPlugin.test.ts | 717 --------------- .../AbstractOutputPlugin.ts | 26 +- .../BaseCLIOutputPlugin.ts | 219 ++++- cli/src/plugins/plugin-output-shared/index.ts | 3 +- .../plugin-output-shared/utils/filters.ts | 8 +- .../utils/pathNormalization.property.test.ts | 57 -- ...esFilter.napi-equivalence.property.test.ts | 107 --- .../utils/seriesFilter.property.test.ts | 154 ---- .../subSeriesGlobExpansion.property.test.ts | 196 ---- .../typeSpecificFilters.property.test.ts | 121 --- ...rIDEPluginOutputPlugin.frontmatter.test.ts | 63 -- ...DEPluginOutputPlugin.projectConfig.test.ts | 118 --- .../QoderIDEPluginOutputPlugin.test.ts | 485 ---------- .../QoderIDEPluginOutputPlugin.ts | 38 +- ...eMdConfigFileOutputPlugin.property.test.ts | 499 ---------- .../types/ConfigTypes.schema.property.test.ts | 92 -- .../plugin-shared/types/ConfigTypes.schema.ts | 69 +- cli/src/plugins/plugin-shared/types/Enums.ts | 2 +- .../types/ExportMetadataTypes.ts | 6 +- .../plugins/plugin-shared/types/InputTypes.ts | 10 +- .../plugin-shared/types/LocalizedTypes.ts | 10 +- .../plugin-shared/types/PluginTypes.ts | 4 +- .../plugin-shared/types/PromptTypes.ts | 2 +- .../seriNamePropagation.property.test.ts | 82 -- .../TraeIDEOutputPlugin.test.ts | 135 --- .../plugin-trae-ide/TraeIDEOutputPlugin.ts | 36 +- .../WarpIDEOutputPlugin.test.ts | 513 ----------- ...WindsurfOutputPlugin.projectConfig.test.ts | 213 ----- .../WindsurfOutputPlugin.property.test.ts | 383 -------- .../WindsurfOutputPlugin.test.ts | 677 -------------- .../plugin-windsurf/WindsurfOutputPlugin.ts | 32 +- cli/src/schema.property.test.ts | 99 -- cli/src/schema.test.ts | 114 --- cli/src/utils/EffectUtils.test.ts | 135 --- cli/src/utils/RelativePathFactory.test.ts | 68 -- cli/src/utils/ResourceUtils.test.ts | 127 --- cli/src/utils/WriteHelper.ts | 4 +- cli/src/utils/ruleFilter.property.test.ts | 254 ------ cli/src/utils/ruleFilter.test.ts | 300 ------ cli/src/versionCheck.test.ts | 88 -- 92 files changed, 672 insertions(+), 16576 deletions(-) delete mode 100644 cli/src/ConfigLoader.test.ts delete mode 100644 cli/src/PluginPipeline.property.test.ts delete mode 100644 cli/src/PluginPipeline.test.ts delete mode 100644 cli/src/commands/Command.test.ts delete mode 100644 cli/src/commands/ConfigShowCommand.test.ts delete mode 100644 cli/src/commands/ExecuteCommand.test.ts delete mode 100644 cli/src/commands/HelpCommand.test.ts delete mode 100644 cli/src/commands/JsonOutput.property.test.ts delete mode 100644 cli/src/commands/JsonOutputCommand.test.ts delete mode 100644 cli/src/commands/PluginsCommand.test.ts delete mode 100644 cli/src/compiler-integration.test.ts delete mode 100644 cli/src/config.test.ts delete mode 100644 cli/src/config/ConfigService.test.ts delete mode 100644 cli/src/config/pathResolver.test.ts delete mode 100644 cli/src/config/schema.test.ts rename cli/src/inputs/{input-fast-command.ts => input-command.ts} (76%) delete mode 100644 cli/src/plugins/desk-paths/index.property.test.ts delete mode 100644 cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.ts delete mode 100644 cli/src/plugins/plugin-antigravity/index.ts delete mode 100644 cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.projectConfig.test.ts delete mode 100644 cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts delete mode 100644 cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts delete mode 100644 cli/src/plugins/plugin-cursor/CursorOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts delete mode 100644 cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.property.test.ts delete mode 100644 cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-output-shared/utils/pathNormalization.property.test.ts delete mode 100644 cli/src/plugins/plugin-output-shared/utils/seriesFilter.napi-equivalence.property.test.ts delete mode 100644 cli/src/plugins/plugin-output-shared/utils/seriesFilter.property.test.ts delete mode 100644 cli/src/plugins/plugin-output-shared/utils/subSeriesGlobExpansion.property.test.ts delete mode 100644 cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts delete mode 100644 cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.frontmatter.test.ts delete mode 100644 cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts delete mode 100644 cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.property.test.ts delete mode 100644 cli/src/plugins/plugin-shared/types/ConfigTypes.schema.property.test.ts delete mode 100644 cli/src/plugins/plugin-shared/types/seriNamePropagation.property.test.ts delete mode 100644 cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.test.ts delete mode 100644 cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts delete mode 100644 cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.property.test.ts delete mode 100644 cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.test.ts delete mode 100644 cli/src/schema.property.test.ts delete mode 100644 cli/src/schema.test.ts delete mode 100644 cli/src/utils/EffectUtils.test.ts delete mode 100644 cli/src/utils/RelativePathFactory.test.ts delete mode 100644 cli/src/utils/ResourceUtils.test.ts delete mode 100644 cli/src/utils/ruleFilter.property.test.ts delete mode 100644 cli/src/utils/ruleFilter.test.ts delete mode 100644 cli/src/versionCheck.test.ts diff --git a/cli/src/ConfigLoader.test.ts b/cli/src/ConfigLoader.test.ts deleted file mode 100644 index 1287dc8a..00000000 --- a/cli/src/ConfigLoader.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -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, loadUserConfig} from './ConfigLoader' - -vi.mock('node:fs') // Mock fs module -vi.mock('node:os') - -describe('configLoader', () => { - const mockHomedir = '/home/testuser' - const mockCwd = '/workspace/project' - - beforeEach(() => { - vi.mocked(os.homedir).mockReturnValue(mockHomedir) - vi.mocked(fs.existsSync).mockReturnValue(false) - vi.mocked(fs.readFileSync).mockReturnValue('{}') - }) - - afterEach(() => vi.clearAllMocks()) - - describe('getSearchPaths', () => { - it('should return default search paths', () => { - const loader = new ConfigLoader() - const paths = loader.getSearchPaths(mockCwd) - - expect(paths).toContain(path.join(mockCwd, DEFAULT_CONFIG_FILE_NAME)) - expect(paths).toContain(path.join(mockHomedir, DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_CONFIG_FILE_NAME)) - }) - - it('should respect searchCwd option', () => { - const loader = new ConfigLoader({searchCwd: false}) - const paths = loader.getSearchPaths(mockCwd) - - expect(paths).not.toContain(path.join(mockCwd, DEFAULT_CONFIG_FILE_NAME)) - }) - - it('should respect searchGlobal option', () => { - const loader = new ConfigLoader({searchGlobal: false}) - const paths = loader.getSearchPaths(mockCwd) - - expect(paths).not.toContain(path.join(mockHomedir, DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_CONFIG_FILE_NAME)) - }) - - it('should include custom search paths', () => { - const customPath = '/custom/config/path' - const loader = new ConfigLoader({searchPaths: [customPath]}) - const paths = loader.getSearchPaths(mockCwd) - - expect(paths[0]).toBe(customPath) - }) - - it('should resolve tilde in custom paths', () => { - const loader = new ConfigLoader({searchPaths: ['~/custom/.tnmsc.json']}) - const paths = loader.getSearchPaths(mockCwd) - - expect(paths[0]).toBe(path.join(mockHomedir, 'custom/.tnmsc.json')) - }) - }) - - describe('loadFromFile', () => { - it('should return empty config when file does not exist', () => { - vi.mocked(fs.existsSync).mockReturnValue(false) - - const loader = new ConfigLoader() - const result = loader.loadFromFile('/nonexistent/.tnmsc.json') - - expect(result.found).toBe(false) - expect(result.config).toEqual({}) - expect(result.source).toBeNull() - }) - - it('should load valid config file', () => { - const configContent = JSON.stringify({workspaceDir: '~/myworkspace', logLevel: 'debug'}) - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue(configContent) - - const loader = new ConfigLoader() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.workspaceDir).toBe('~/myworkspace') - expect(result.config.logLevel).toBe('debug') - }) - - it('should throw error for invalid JSON', () => { - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }') - - const loader = new ConfigLoader() - expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Invalid JSON') // Now throws error instead of returning empty config - }) - - it('should throw error for invalid string fields', () => { - const configContent = JSON.stringify({ // workspaceDir is invalid (number instead of string) - workspaceDir: 123 - }) - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue(configContent) - - const loader = new ConfigLoader() - expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Config validation failed') // Now throws error instead of ignoring invalid field - }) - - it('should throw error for invalid logLevel values', () => { - const configContent = JSON.stringify({ // logLevel is invalid - logLevel: 'invalid' - }) - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue(configContent) - - const loader = new ConfigLoader() - expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Config validation failed') // Now throws error instead of ignoring invalid field - }) - - it('should validate shadowSourceProject object', () => { - const configContent = JSON.stringify({ - shadowSourceProject: { - name: 'aindex', - 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'} - } - }) - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue(configContent) - - const loader = new ConfigLoader() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.shadowSourceProject?.name).toBe('aindex') - expect(result.config.shadowSourceProject?.skill).toEqual({src: 'src/skills', dist: 'dist/skills'}) - }) - - it('should reject invalid shadowSourceProject (non-object)', () => { - const configContent = JSON.stringify({shadowSourceProject: 'invalid'}) - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue(configContent) - - const loader = new ConfigLoader() - expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Config validation failed') // Now throws error instead of returning config with undefined shadowSourceProject - }) - - it('should validate profile object with arbitrary key-value pairs', () => { - const configContent = JSON.stringify({ - profile: { - name: 'Zhang San', - username: 'zhangsan', - gender: 'male', - birthday: '1990-01-01', - customField: 'custom value' - } - }) - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue(configContent) - - const loader = new ConfigLoader() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.profile).toEqual({ - name: 'Zhang San', - username: 'zhangsan', - gender: 'male', - birthday: '1990-01-01', - customField: 'custom value' - }) - }) - - it('should reject invalid profile (non-object)', () => { - const configContent = JSON.stringify({profile: 'invalid'}) - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue(configContent) - - const loader = new ConfigLoader() - expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Config validation failed') // Now throws error instead of returning empty config - }) - - it('should reject invalid profile (array)', () => { - const configContent = JSON.stringify({ - profile: ['invalid'] - }) - - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.readFileSync).mockReturnValue(configContent) - - const loader = new ConfigLoader() - expect(() => loader.loadFromFile('/test/.tnmsc.json')).toThrow('Config validation failed') // Now throws error instead of returning empty config - }) - }) - - describe('load', () => { - it('should throw error when no config files found', () => { - vi.mocked(fs.existsSync).mockReturnValue(false) - - const loader = new ConfigLoader() - expect(() => loader.load(mockCwd)).toThrow('No valid config file found') // Now throws error instead of returning empty config - }) - - it('should merge configs with correct priority', () => { - const cwdConfig = JSON.stringify({workspaceDir: '~/cwd-workspace', logLevel: 'debug'}) - - const globalConfig = JSON.stringify({ - workspaceDir: '~/global-workspace', - shadowSourceProject: { - name: 'global-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: 'info' - }) - - const cwdPath = path.join(mockCwd, DEFAULT_CONFIG_FILE_NAME) - const globalPath = path.join(mockHomedir, DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_CONFIG_FILE_NAME) - - vi.mocked(fs.existsSync).mockImplementation(p => p === cwdPath || p === globalPath) - - vi.mocked(fs.readFileSync).mockImplementation(p => { - if (p === cwdPath) return cwdConfig - if (p === globalPath) return globalConfig - return '{}' - }) - - const loader = new ConfigLoader() - const result = loader.load(mockCwd) - - expect(result.found).toBe(true) - expect(result.config.workspaceDir).toBe('~/cwd-workspace') // CWD config should override global - expect(result.config.logLevel).toBe('debug') - expect(result.config.shadowSourceProject?.name).toBe('global-shadow') // Global config should fill in missing values - expect(result.sources).toHaveLength(2) - }) - - it('should deep merge shadowSourceProject', () => { - const cwdConfig = JSON.stringify({ - shadowSourceProject: { - name: 'cwd-shadow', - skill: {src: 'custom/skills', dist: 'custom/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 globalConfig = JSON.stringify({ - shadowSourceProject: { - name: 'global-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 cwdPath = path.join(mockCwd, DEFAULT_CONFIG_FILE_NAME) - const globalPath = path.join(mockHomedir, DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_CONFIG_FILE_NAME) - - vi.mocked(fs.existsSync).mockImplementation(p => p === cwdPath || p === globalPath) - - vi.mocked(fs.readFileSync).mockImplementation(p => { - if (p === cwdPath) return cwdConfig - if (p === globalPath) return globalConfig - return '{}' - }) - - const loader = new ConfigLoader() - const result = loader.load(mockCwd) - - expect(result.config.shadowSourceProject?.name).toBe('cwd-shadow') // CWD name overrides global - expect(result.config.shadowSourceProject?.skill?.src).toBe('custom/skills') // CWD pair overrides global - expect(result.config.shadowSourceProject?.fastCommand?.src).toBe('src/commands') // Global fills in missing pairs - }) - }) - - describe('loadUserConfig helper', () => { - it('should throw error when no config found', () => { - vi.mocked(fs.existsSync).mockReturnValue(false) - - expect(() => loadUserConfig(mockCwd)).toThrow('No valid config file found') // Now throws error instead of returning empty config - }) - }) -}) diff --git a/cli/src/ConfigLoader.ts b/cli/src/ConfigLoader.ts index 51e7cd33..757b2ea0 100644 --- a/cli/src/ConfigLoader.ts +++ b/cli/src/ConfigLoader.ts @@ -170,7 +170,7 @@ export class ConfigLoader { return { name: b.name ?? a.name, skill: {...a.skill, ...b.skill}, - fastCommand: {...a.fastCommand, ...b.fastCommand}, + command: {...a.command, ...b.command}, subAgent: {...a.subAgent, ...b.subAgent}, rule: {...a.rule, ...b.rule}, globalMemory: {...a.globalMemory, ...b.globalMemory}, diff --git a/cli/src/PluginPipeline.property.test.ts b/cli/src/PluginPipeline.property.test.ts deleted file mode 100644 index 68d37ccb..00000000 --- a/cli/src/PluginPipeline.property.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import type {LogLevel, ParsedCliArgs} from '@/PluginPipeline' -import fc from 'fast-check' -import {describe, expect, it} from 'vitest' -import {DryRunCleanCommand, ExecuteCommand, HelpCommand} from '@/commands' -import {parseArgs, resolveCommand, resolveLogLevel} from '@/PluginPipeline' - -/** - * Feature: cli-refactor - * Property-based tests for argument parsing - */ -describe('parseArgs property tests', () => { - describe('property 2: Log Level Flag Parsing', () => { - const logLevelFlags = [ - {flag: '--trace', level: 'trace'}, - {flag: '--debug', level: 'debug'}, - {flag: '--info', level: 'info'}, - {flag: '--warn', level: 'warn'}, - {flag: '--error', level: 'error'} - ] as const - - it('should parse single log level flag correctly', () => { - fc.assert( - fc.property( - fc.constantFrom(...logLevelFlags), - fc.array(fc.string().filter(s => !s.startsWith('-') && s.length > 0), {maxLength: 5}), - ({flag, level}, otherArgs) => { - const filteredArgs = otherArgs.filter( // Filter out any strings that might be valid subcommands - arg => !['help', 'init', 'dry-run', 'clean'].includes(arg) - ) - const args = [flag, ...filteredArgs] - const result = parseArgs(args) - expect(result.logLevel).toBe(level) - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 3: Log Level Default Behavior', () => { - const logLevelFlags = new Set(['--trace', '--debug', '--info', '--warn', '--error']) - - it('should have undefined logLevel when no log level flag is provided', () => { - fc.assert( - fc.property( - fc.array( - fc.string().filter(s => !logLevelFlags.has(s) && s.length > 0), - {maxLength: 10} - ), - args => { - const result = parseArgs(args) - expect(result.logLevel).toBeUndefined() - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 5: Unknown Subcommand Detection', () => { - const validSubcommands = ['help', 'init', 'dry-run', 'clean'] - - it('should capture unknown first positional as unknownCommand', () => { - fc.assert( - fc.property( - fc.string({minLength: 1}).filter(s => - !validSubcommands.includes(s) && !s.startsWith('-') && s.trim().length > 0), // Must not be empty // Must not start with '-' // Must not be a valid subcommand - unknownCmd => { - const result = parseArgs([unknownCmd]) - expect(result.unknownCommand).toBe(unknownCmd) - expect(result.subcommand).toBeUndefined() - } - ), - {numRuns: 100} - ) - }) - - it('should not set unknownCommand for valid subcommands', () => { - fc.assert( - fc.property( - fc.constantFrom(...validSubcommands), - subcommand => { - const result = parseArgs([subcommand]) - expect(result.unknownCommand).toBeUndefined() - expect(result.subcommand).toBe(subcommand) - } - ), - {numRuns: 100} - ) - }) - }) -}) - -/** - * Feature: cli-refactor - * Property-based tests for log level resolution - */ -describe('resolveLogLevel property tests', () => { - describe('property 4: Log Level Priority Resolution', () => { - const allLogLevels: LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error'] - const logLevelPriority: Record = { - trace: 0, - debug: 1, - info: 2, - warn: 3, - error: 4 - } - - it('should resolve to most verbose level when multiple flags provided', () => { - fc.assert( - fc.property( - fc.array(fc.constantFrom(...allLogLevels), {minLength: 1, maxLength: 5}), // Generate a non-empty subset of log levels - levels => { - const args = levels.map(level => `--${level}`) // Build args with log level flags - const parsed = parseArgs(args) - const resolved = resolveLogLevel(parsed) - - const expectedLevel = levels.reduce((mostVerbose, current) => logLevelPriority[current] < logLevelPriority[mostVerbose] // Find expected most verbose level - ? current - : mostVerbose) - - expect(resolved).toBe(expectedLevel) - } - ), - {numRuns: 100} - ) - }) - - it('should return undefined when no log level flags provided', () => { - fc.assert( - fc.property( - fc.array( - fc.string().filter(s => - !allLogLevels.some(level => s === `--${level}`)), // Exclude log level flags - {maxLength: 10} - ), - args => { - const parsed = parseArgs(args) - const resolved = resolveLogLevel(parsed) - expect(resolved).toBeUndefined() - } - ), - {numRuns: 100} - ) - }) - - it('should always return trace when trace is among the flags', () => { - fc.assert( - fc.property( - fc.array(fc.constantFrom('debug', 'info', 'warn', 'error') as fc.Arbitrary, {maxLength: 4}), // Generate other log levels (not trace) - otherLevels => { - const args = ['--trace', ...otherLevels.map(level => `--${level}`)] // Always include trace - const parsed = parseArgs(args) - const resolved = resolveLogLevel(parsed) - - expect(resolved).toBe('trace') - } - ), - {numRuns: 100} - ) - }) - }) -}) - -/** - * Feature: cli-refactor - * Property-based tests for command resolution - */ -describe('resolveCommand property tests', () => { - function createParsedArgs(overrides: Partial = {}): ParsedCliArgs { - return { - subcommand: void 0, - helpFlag: false, - versionFlag: false, - dryRun: false, - logLevel: void 0, - logLevelFlags: [], - setOption: [], - unknownCommand: void 0, - positional: [], - unknown: [], - ...overrides - } - } - - describe('property 1: Default Command Resolution', () => { - it('should return ExecuteCommand for empty/default args', () => { - fc.assert( - fc.property( - fc.array( // Generate arrays of non-flag, non-subcommand strings (positional args) - fc.string().filter(s => - !s.startsWith('-') // Exclude flags and valid subcommands - && !['help', 'init', 'dry-run', 'clean'].includes(s) - && s.trim().length === 0), - {maxLength: 5} - ), - _emptyArgs => { - const args = createParsedArgs() // Create args with no subcommand, no helpFlag, no unknownCommand - const command = resolveCommand(args) - expect(command).toBeInstanceOf(ExecuteCommand) - } - ), - {numRuns: 100} - ) - }) - - it('should return ExecuteCommand when only positional args are present', () => { - fc.assert( - fc.property( - fc.array(fc.string({minLength: 1}), {maxLength: 5}), - positionalArgs => { - const args = createParsedArgs({positional: positionalArgs}) // Create args with positional but no subcommand/flags - const command = resolveCommand(args) - expect(command).toBeInstanceOf(ExecuteCommand) - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 6: Help Flag Equivalence', () => { - const validSubcommands = ['help', 'init', 'dry-run', 'clean'] as const - - it('should return HelpCommand when helpFlag is true regardless of subcommand', () => { - fc.assert( - fc.property( - fc.option(fc.constantFrom(...validSubcommands), {nil: void 0}), - fc.boolean(), - fc.option(fc.string({minLength: 1}), {nil: void 0}), - (subcommand, dryRun, unknownCommand) => { - const args = createParsedArgs({helpFlag: true, subcommand, dryRun, unknownCommand}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(HelpCommand) - } - ), - {numRuns: 100} - ) - }) - - it('should return HelpCommand when helpFlag is true with any log level', () => { - fc.assert( - fc.property( - fc.constantFrom('trace', 'debug', 'info', 'warn', 'error') as fc.Arbitrary, - logLevel => { - const args = createParsedArgs({ - helpFlag: true, - logLevel, - logLevelFlags: [logLevel] - }) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(HelpCommand) - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 7: Clean Dry-Run Flag Parsing', () => { - it('should return DryRunCleanCommand when clean subcommand with dryRun flag', () => { - fc.assert( - fc.property( - fc.option(fc.constantFrom('trace', 'debug', 'info', 'warn', 'error') as fc.Arbitrary, {nil: void 0}), - fc.array(fc.string(), {maxLength: 5}), - (logLevel, positional) => { - const args = createParsedArgs({ - subcommand: 'clean', - dryRun: true, - logLevel, - logLevelFlags: logLevel != null ? [logLevel] : [], - positional - }) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(DryRunCleanCommand) - } - ), - {numRuns: 100} - ) - }) - - it('should return DryRunCleanCommand regardless of other flags when clean + dryRun', () => { - fc.assert( - fc.property( - fc.array(fc.string(), {maxLength: 5}), - unknownFlags => { - const args = createParsedArgs({subcommand: 'clean', dryRun: true, unknown: unknownFlags}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(DryRunCleanCommand) - } - ), - {numRuns: 100} - ) - }) - }) -}) diff --git a/cli/src/PluginPipeline.test.ts b/cli/src/PluginPipeline.test.ts deleted file mode 100644 index 88e7b99f..00000000 --- a/cli/src/PluginPipeline.test.ts +++ /dev/null @@ -1,819 +0,0 @@ -import type {CollectedInputContext, InputPlugin, InputPluginContext, Plugin} from '@truenine/plugin-shared' -import type {ParsedCliArgs} from '@/PluginPipeline' -import fs from 'node:fs' -import path from 'node:path' -import {CircularDependencyError, createLogger, FilePathKind, MissingDependencyError, PluginKind, PromptKind} from '@truenine/plugin-shared' -import glob from 'fast-glob' -import {describe, expect, it} from 'vitest' -import { - CleanCommand, - DryRunCleanCommand, - DryRunOutputCommand, - ExecuteCommand, - HelpCommand, - UnknownCommand -} from '@/commands' -import {parseArgs, PluginPipeline, resolveCommand, resolveLogLevel} from '@/PluginPipeline' - -function createMockPlugin(name: string, dependsOn?: readonly string[]): Plugin { - const base = { - type: PluginKind.Input, - name, - log: {} as Plugin['log'] - } - if (dependsOn) return {...base, dependsOn} - return base -} - -function createMockInputPlugin( - name: string, - collectFn: (ctx: InputPluginContext) => Partial, - dependsOn?: readonly string[] -): InputPlugin { - const base = { - type: PluginKind.Input as const, - name, - log: createLogger(name), - collect: collectFn - } - if (dependsOn) return {...base, dependsOn} - return base -} - -function createBaseContext(): Omit { - return { - logger: createLogger('test'), - fs, - path, - glob, - userConfigOptions: {} - } -} - -function createMockPath(pathStr: string): CollectedInputContext['workspace']['directory'] { - return { - pathKind: FilePathKind.Absolute, - path: pathStr, - getDirectoryName: () => pathStr.split('/').pop() ?? '' - } as CollectedInputContext['workspace']['directory'] -} - -describe('pluginPipeline', () => { - describe('buildDependencyGraph', () => { - it('should return empty graph for empty plugins array', () => { - const pipeline = new PluginPipeline() - const graph = pipeline.buildDependencyGraph([]) - expect(graph.size).toBe(0) - }) - - it('should build graph for plugins without dependencies', () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockPlugin('A'), - createMockPlugin('B') - ] - const graph = pipeline.buildDependencyGraph(plugins) - - expect(graph.size).toBe(2) - expect(graph.get('A')).toEqual([]) - expect(graph.get('B')).toEqual([]) - }) - - it('should build graph for plugins with dependencies', () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockPlugin('A', ['B', 'C']), - createMockPlugin('B', ['C']), - createMockPlugin('C') - ] - const graph = pipeline.buildDependencyGraph(plugins) - - expect(graph.size).toBe(3) - expect(graph.get('A')).toEqual(['B', 'C']) - expect(graph.get('B')).toEqual(['C']) - expect(graph.get('C')).toEqual([]) - }) - }) - - describe('validateDependencies', () => { - it('should pass for plugins without dependencies', () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockPlugin('A'), - createMockPlugin('B') - ] - expect(() => pipeline.validateDependencies(plugins)).not.toThrow() - }) - - it('should pass for valid dependencies', () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockPlugin('A', ['B']), - createMockPlugin('B', ['C']), - createMockPlugin('C') - ] - expect(() => pipeline.validateDependencies(plugins)).not.toThrow() - }) - - it('should throw MissingDependencyError for non-existent dependency', () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockPlugin('A', ['NonExistent']), - createMockPlugin('B') - ] - - expect(() => pipeline.validateDependencies(plugins)).toThrow(MissingDependencyError) - try { - pipeline.validateDependencies(plugins) - } - catch (e) { - expect(e).toBeInstanceOf(MissingDependencyError) - const error = e as MissingDependencyError - expect(error.pluginName).toBe('A') - expect(error.missingDependency).toBe('NonExistent') - } - }) - - it('should throw for first missing dependency found', () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockPlugin('A', ['Missing1', 'Missing2']) - ] - - expect(() => pipeline.validateDependencies(plugins)).toThrow(MissingDependencyError) - }) - }) - - describe('topologicalSort', () => { - it('should return empty array for empty plugins array', () => { - const pipeline = new PluginPipeline() - const result = pipeline.topologicalSort([]) - expect(result).toEqual([]) - }) - - it('should return single plugin for single plugin array', () => { - const pipeline = new PluginPipeline() - const plugins = [createMockPlugin('A')] - const result = pipeline.topologicalSort(plugins) - expect(result.map(p => p.name)).toEqual(['A']) - }) - - it('should preserve registration order for plugins without dependencies', () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockPlugin('A'), - createMockPlugin('B'), - createMockPlugin('C') - ] - const result = pipeline.topologicalSort(plugins) - expect(result.map(p => p.name)).toEqual(['A', 'B', 'C']) - }) - - it('should sort plugins with linear dependency chain', () => { - const pipeline = new PluginPipeline() - const plugins = [ // A depends on B, B depends on C - createMockPlugin('A', ['B']), - createMockPlugin('B', ['C']), - createMockPlugin('C') - ] - const result = pipeline.topologicalSort(plugins) - const names = result.map(p => p.name) - - expect(names.indexOf('C')).toBeLessThan(names.indexOf('B')) // C must come before B, B must come before A - expect(names.indexOf('B')).toBeLessThan(names.indexOf('A')) - }) - - it('should handle diamond dependency pattern', () => { - const pipeline = new PluginPipeline() - const plugins = [ // A depends on B and C, both B and C depend on D - createMockPlugin('A', ['B', 'C']), - createMockPlugin('B', ['D']), - createMockPlugin('C', ['D']), - createMockPlugin('D') - ] - const result = pipeline.topologicalSort(plugins) - const names = result.map(p => p.name) - - expect(names.indexOf('D')).toBeLessThan(names.indexOf('B')) // D must come before B and C, B and C must come before A - expect(names.indexOf('D')).toBeLessThan(names.indexOf('C')) - expect(names.indexOf('B')).toBeLessThan(names.indexOf('A')) - expect(names.indexOf('C')).toBeLessThan(names.indexOf('A')) - }) - - it('should preserve registration order for independent plugins', () => { - const pipeline = new PluginPipeline() - const plugins = [ // D has no deps, A depends on B, C has no deps - createMockPlugin('D'), - createMockPlugin('A', ['B']), - createMockPlugin('B'), - createMockPlugin('C') - ] - const result = pipeline.topologicalSort(plugins) - const names = result.map(p => p.name) - - expect(names.indexOf('B')).toBeLessThan(names.indexOf('A')) // B must come before A - expect(names.indexOf('D')).toBeLessThan(names.indexOf('C')) // D should come before C (registration order for independent plugins) - }) - - it('should throw CircularDependencyError for simple cycle', () => { - const pipeline = new PluginPipeline() - const plugins = [ // A depends on B, B depends on A - createMockPlugin('A', ['B']), - createMockPlugin('B', ['A']) - ] - - expect(() => pipeline.topologicalSort(plugins)).toThrow(CircularDependencyError) - }) - - it('should throw CircularDependencyError for longer cycle', () => { - const pipeline = new PluginPipeline() - const plugins = [ // A -> B -> C -> A - createMockPlugin('A', ['B']), - createMockPlugin('B', ['C']), - createMockPlugin('C', ['A']) - ] - - expect(() => pipeline.topologicalSort(plugins)).toThrow(CircularDependencyError) - }) - - it('should include cycle path in CircularDependencyError', () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockPlugin('A', ['B']), - createMockPlugin('B', ['A']) - ] - - try { - pipeline.topologicalSort(plugins) - expect.fail('Should have thrown CircularDependencyError') - } - catch (e) { - expect(e).toBeInstanceOf(CircularDependencyError) - const error = e as CircularDependencyError - expect(error.cycle).toContain('A') // Cycle should contain both A and B - expect(error.cycle).toContain('B') - } - }) - - it('should throw MissingDependencyError for non-existent dependency', () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockPlugin('A', ['NonExistent']) - ] - - expect(() => pipeline.topologicalSort(plugins)).toThrow(MissingDependencyError) - }) - - it('should handle self-dependency as cycle', () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockPlugin('A', ['A']) - ] - - expect(() => pipeline.topologicalSort(plugins)).toThrow(CircularDependencyError) - }) - }) - - describe('executePluginsInOrder', () => { - it('should return empty object for empty plugins array', async () => { - const pipeline = new PluginPipeline() - const result = await pipeline.executePluginsInOrder([], createBaseContext()) - expect(result).toEqual({}) - }) - - it('should execute single plugin and return its output', async () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockInputPlugin('A', () => ({ - workspace: {directory: createMockPath('/test'), projects: []} - })) - ] - - const result = await pipeline.executePluginsInOrder(plugins, createBaseContext()) - expect(result.workspace?.directory.path).toBe('/test') - }) - - it('should execute plugins in dependency order', async () => { - const pipeline = new PluginPipeline() - const executionOrder: string[] = [] - - const plugins = [ - createMockInputPlugin('A', () => { - executionOrder.push('A') - return {} - }, ['B']), - createMockInputPlugin('B', () => { - executionOrder.push('B') - return {} - }) - ] - - await pipeline.executePluginsInOrder(plugins, createBaseContext()) - expect(executionOrder).toEqual(['B', 'A']) - }) - - it('should merge outputs from all plugins', async () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockInputPlugin('A', () => ({ - workspace: { - directory: createMockPath('/test'), - projects: [{name: 'project-a'}] - } - })), - createMockInputPlugin('B', () => ({ - workspace: { - directory: createMockPath('/test'), - projects: [{name: 'project-b'}] - } - })) - ] - - const result = await pipeline.executePluginsInOrder(plugins, createBaseContext()) - expect(result.workspace?.projects).toHaveLength(2) - expect(result.workspace?.projects.map(p => p.name)).toContain('project-a') - expect(result.workspace?.projects.map(p => p.name)).toContain('project-b') - }) - - it('should pass dependency context to dependent plugins', async () => { - const pipeline = new PluginPipeline() - let receivedContext: Partial | undefined - - const plugins = [ - createMockInputPlugin('B', () => ({ - workspace: { - directory: createMockPath('/test'), - projects: [{name: 'from-B'}] - } - })), - createMockInputPlugin('A', ctx => { - receivedContext = ctx.dependencyContext - return {} - }, ['B']) - ] - - await pipeline.executePluginsInOrder(plugins, createBaseContext()) - - expect(receivedContext).toBeDefined() - expect(receivedContext?.workspace?.projects).toHaveLength(1) - expect(receivedContext?.workspace?.projects[0]?.name).toBe('from-B') - }) - - it('should provide empty dependency context for plugins without dependencies', async () => { - const pipeline = new PluginPipeline() - let receivedContext: Partial | undefined - - const plugins = [ - createMockInputPlugin('A', ctx => { - receivedContext = ctx.dependencyContext - return {} - }) - ] - - await pipeline.executePluginsInOrder(plugins, createBaseContext()) - expect(receivedContext).toEqual({}) - }) - - it('should handle diamond dependency pattern with correct context', async () => { - const pipeline = new PluginPipeline() - const executionOrder: string[] = [] - let aReceivedContext: Partial | undefined - - const plugins = [ // A depends on B and C, both B and C depend on D - createMockInputPlugin('A', ctx => { - executionOrder.push('A') - aReceivedContext = ctx.dependencyContext - return {} - }, ['B', 'C']), - createMockInputPlugin('B', () => { - executionOrder.push('B') - return { - workspace: { - directory: createMockPath('/test'), - projects: [{name: 'from-B'}] - } - } - }, ['D']), - createMockInputPlugin('C', () => { - executionOrder.push('C') - return { - workspace: { - directory: createMockPath('/test'), - projects: [{name: 'from-C'}] - } - } - }, ['D']), - createMockInputPlugin('D', () => { - executionOrder.push('D') - return { - workspace: { - directory: createMockPath('/test'), - projects: [{name: 'from-D'}] - } - } - }) - ] - - await pipeline.executePluginsInOrder(plugins, createBaseContext()) - - expect(executionOrder.indexOf('D')).toBeLessThan(executionOrder.indexOf('B')) // D must execute first - expect(executionOrder.indexOf('D')).toBeLessThan(executionOrder.indexOf('C')) - expect(executionOrder.indexOf('B')).toBeLessThan(executionOrder.indexOf('A')) // B and C must execute before A - expect(executionOrder.indexOf('C')).toBeLessThan(executionOrder.indexOf('A')) - - expect(aReceivedContext?.workspace?.projects).toBeDefined() // A should receive context from B and C (its direct dependencies) - const projectNames = aReceivedContext?.workspace?.projects.map(p => p.name) ?? [] - expect(projectNames).toContain('from-B') - expect(projectNames).toContain('from-C') - }) - - it('should merge array fields correctly', async () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockInputPlugin('A', () => ({ - fastCommands: [{type: 1, name: 'cmd-a'} as unknown as CollectedInputContext['fastCommands'] extends readonly (infer T)[] | undefined ? T : never] - })), - createMockInputPlugin('B', () => ({ - fastCommands: [{type: 1, name: 'cmd-b'} as unknown as CollectedInputContext['fastCommands'] extends readonly (infer T)[] | undefined ? T : never] - })) - ] - - const result = await pipeline.executePluginsInOrder(plugins, createBaseContext()) - expect(result.fastCommands).toHaveLength(2) - }) - - it('should use last globalMemory when multiple plugins provide it', async () => { - const pipeline = new PluginPipeline() - const mockGlobalMemoryA = { - type: PromptKind.GlobalMemory, - content: 'from-A', - parentDirectoryPath: {}, - markdownContents: [], - dir: createMockPath('/test'), - length: 6, - filePathKind: FilePathKind.Relative - } as unknown as NonNullable - const mockGlobalMemoryB = { - type: PromptKind.GlobalMemory, - content: 'from-B', - parentDirectoryPath: {}, - markdownContents: [], - dir: createMockPath('/test'), - length: 6, - filePathKind: FilePathKind.Relative - } as unknown as NonNullable - - const plugins = [ - createMockInputPlugin('A', () => ({ - globalMemory: mockGlobalMemoryA - })), - createMockInputPlugin('B', () => ({ - globalMemory: mockGlobalMemoryB - })) - ] - - const result = await pipeline.executePluginsInOrder(plugins, createBaseContext()) - expect(result.globalMemory?.content).toBe('from-B') - }) - - it('should use last shadowGitExclude when multiple plugins provide it', async () => { - const pipeline = new PluginPipeline() - const plugins = [ - createMockInputPlugin('A', () => ({ - shadowGitExclude: 'from-A' - })), - createMockInputPlugin('B', () => ({ - shadowGitExclude: 'from-B' - })) - ] - - const result = await pipeline.executePluginsInOrder(plugins, createBaseContext()) - expect(result.shadowGitExclude).toBe('from-B') - }) - }) -}) - -/** - * Unit tests for argument parsing - */ -describe('parseArgs', () => { - describe('subcommand parsing', () => { - it('should parse "help" subcommand', () => { - const result = parseArgs(['help']) - expect(result.subcommand).toBe('help') - expect(result.unknownCommand).toBeUndefined() - }) - - it('should parse "init" subcommand', () => { - const result = parseArgs(['init']) - expect(result.subcommand).toBe('init') - expect(result.unknownCommand).toBeUndefined() - }) - - it('should parse "dry-run" subcommand', () => { - const result = parseArgs(['dry-run']) - expect(result.subcommand).toBe('dry-run') - expect(result.unknownCommand).toBeUndefined() - }) - - it('should parse "clean" subcommand', () => { - const result = parseArgs(['clean']) - expect(result.subcommand).toBe('clean') - expect(result.unknownCommand).toBeUndefined() - }) - - it('should return undefined subcommand for empty args', () => { - const result = parseArgs([]) - expect(result.subcommand).toBeUndefined() - expect(result.unknownCommand).toBeUndefined() - }) - - it('should capture unknown first positional as unknownCommand', () => { - const result = parseArgs(['foo']) - expect(result.subcommand).toBeUndefined() - expect(result.unknownCommand).toBe('foo') - }) - }) - - describe('help flag parsing', () => { - it('should parse --help flag', () => { - const result = parseArgs(['--help']) - expect(result.helpFlag).toBe(true) - }) - - it('should parse -h flag', () => { - const result = parseArgs(['-h']) - expect(result.helpFlag).toBe(true) - }) - - it('should parse --help with subcommand', () => { - const result = parseArgs(['init', '--help']) - expect(result.helpFlag).toBe(true) - expect(result.subcommand).toBe('init') - }) - }) - - describe('dry-run flag parsing', () => { - it('should parse --dry-run flag', () => { - const result = parseArgs(['--dry-run']) - expect(result.dryRun).toBe(true) - }) - - it('should parse -n flag', () => { - const result = parseArgs(['-n']) - expect(result.dryRun).toBe(true) - }) - - it('should parse clean --dry-run', () => { - const result = parseArgs(['clean', '--dry-run']) - expect(result.subcommand).toBe('clean') - expect(result.dryRun).toBe(true) - }) - - it('should parse clean -n', () => { - const result = parseArgs(['clean', '-n']) - expect(result.subcommand).toBe('clean') - expect(result.dryRun).toBe(true) - }) - }) - - describe('log level parsing', () => { - it('should parse --trace flag', () => { - const result = parseArgs(['--trace']) - expect(result.logLevel).toBe('trace') - expect(result.logLevelFlags).toContain('trace') - }) - - it('should parse --debug flag', () => { - const result = parseArgs(['--debug']) - expect(result.logLevel).toBe('debug') - expect(result.logLevelFlags).toContain('debug') - }) - - it('should parse --info flag', () => { - const result = parseArgs(['--info']) - expect(result.logLevel).toBe('info') - expect(result.logLevelFlags).toContain('info') - }) - - it('should parse --warn flag', () => { - const result = parseArgs(['--warn']) - expect(result.logLevel).toBe('warn') - expect(result.logLevelFlags).toContain('warn') - }) - - it('should parse --error flag', () => { - const result = parseArgs(['--error']) - expect(result.logLevel).toBe('error') - expect(result.logLevelFlags).toContain('error') - }) - - it('should have undefined logLevel when no flag provided', () => { - const result = parseArgs([]) - expect(result.logLevel).toBeUndefined() - expect(result.logLevelFlags).toHaveLength(0) - }) - - it('should collect multiple log level flags', () => { - const result = parseArgs(['--debug', '--trace', '--info']) - expect(result.logLevelFlags).toContain('debug') - expect(result.logLevelFlags).toContain('trace') - expect(result.logLevelFlags).toContain('info') - expect(result.logLevelFlags).toHaveLength(3) - }) - }) - - describe('unknown flags', () => { - it('should collect unknown long flags', () => { - const result = parseArgs(['--unknown-flag']) - expect(result.unknown).toContain('--unknown-flag') - }) - - it('should collect unknown short flags', () => { - const result = parseArgs(['-x']) - expect(result.unknown).toContain('-x') - }) - }) - - describe('positional arguments', () => { - it('should collect positional arguments after subcommand', () => { - const result = parseArgs(['init', 'arg1', 'arg2']) - expect(result.subcommand).toBe('init') - expect(result.positional).toContain('arg1') - expect(result.positional).toContain('arg2') - }) - - it('should handle -- separator', () => { - const result = parseArgs(['init', '--', '--help', 'arg']) - expect(result.subcommand).toBe('init') - expect(result.helpFlag).toBe(false) - expect(result.positional).toContain('--help') - expect(result.positional).toContain('arg') - }) - }) - - describe('parsedCliArgs structure', () => { - it('should return complete ParsedCliArgs structure', () => { - const result = parseArgs(['clean', '--dry-run', '--debug']) - expect(result).toMatchObject({ - subcommand: 'clean', - helpFlag: false, - dryRun: true, - logLevel: 'debug', - unknownCommand: void 0 - } satisfies Partial) - expect(result.logLevelFlags).toContain('debug') - expect(Array.isArray(result.positional)).toBe(true) - expect(Array.isArray(result.unknown)).toBe(true) - }) - }) -}) - -/** - * Unit tests for log level resolution - */ -describe('resolveLogLevel', () => { - function createParsedArgs(overrides: Partial = {}): ParsedCliArgs { - return { - subcommand: void 0, - helpFlag: false, - versionFlag: false, - dryRun: false, - logLevel: void 0, - logLevelFlags: [], - setOption: [], - unknownCommand: void 0, - positional: [], - unknown: [], - ...overrides - } - } - - it('should return undefined when no log level flags provided', () => { - const args = createParsedArgs() - expect(resolveLogLevel(args)).toBeUndefined() - }) - - it('should return single log level when one flag provided', () => { - const args = createParsedArgs({logLevelFlags: ['debug']}) - expect(resolveLogLevel(args)).toBe('debug') - }) - - it('should return most verbose level (trace) when multiple flags provided', () => { - const args = createParsedArgs({logLevelFlags: ['error', 'trace', 'warn']}) - expect(resolveLogLevel(args)).toBe('trace') - }) - - it('should return debug over info when both provided', () => { - const args = createParsedArgs({logLevelFlags: ['info', 'debug']}) - expect(resolveLogLevel(args)).toBe('debug') - }) - - it('should return info over warn when both provided', () => { - const args = createParsedArgs({logLevelFlags: ['warn', 'info']}) - expect(resolveLogLevel(args)).toBe('info') - }) -}) - -/** - * Unit tests for command resolution - */ -describe('resolveCommand', () => { - function createParsedArgs(overrides: Partial = {}): ParsedCliArgs { - return { - subcommand: void 0, - helpFlag: false, - versionFlag: false, - dryRun: false, - logLevel: void 0, - logLevelFlags: [], - setOption: [], - unknownCommand: void 0, - positional: [], - unknown: [], - ...overrides - } - } - - describe('default command', () => { - it('should return ExecuteCommand for empty args', () => { - const args = createParsedArgs() - const command = resolveCommand(args) - expect(command).toBeInstanceOf(ExecuteCommand) - }) - }) - - describe('help command', () => { - it('should return HelpCommand for help subcommand', () => { - const args = createParsedArgs({subcommand: 'help'}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(HelpCommand) - }) - - it('should return HelpCommand for --help flag', () => { - const args = createParsedArgs({helpFlag: true}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(HelpCommand) - }) - - it('should return HelpCommand for -h flag', () => { - const args = createParsedArgs({helpFlag: true}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(HelpCommand) - }) - - it('should prioritize helpFlag over subcommand', () => { - const args = createParsedArgs({helpFlag: true, subcommand: 'init'}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(HelpCommand) - }) - - it('should prioritize helpFlag over unknownCommand', () => { - const args = createParsedArgs({helpFlag: true, unknownCommand: 'foo'}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(HelpCommand) - }) - }) - - describe('dry-run command', () => { - it('should return DryRunOutputCommand for dry-run subcommand', () => { - const args = createParsedArgs({subcommand: 'dry-run'}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(DryRunOutputCommand) - }) - }) - - describe('clean command', () => { - it('should return CleanCommand for clean subcommand', () => { - const args = createParsedArgs({subcommand: 'clean'}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(CleanCommand) - }) - - it('should return DryRunCleanCommand for clean --dry-run', () => { - const args = createParsedArgs({subcommand: 'clean', dryRun: true}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(DryRunCleanCommand) - }) - - it('should return DryRunCleanCommand for clean -n', () => { - const args = createParsedArgs({subcommand: 'clean', dryRun: true}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(DryRunCleanCommand) - }) - }) - - describe('unknown command', () => { - it('should return UnknownCommand for unknown subcommand', () => { - const args = createParsedArgs({unknownCommand: 'foo'}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(UnknownCommand) - }) - - it('should prioritize unknownCommand over default', () => { - const args = createParsedArgs({unknownCommand: 'bar'}) - const command = resolveCommand(args) - expect(command).toBeInstanceOf(UnknownCommand) - }) - }) -}) diff --git a/cli/src/commands/Command.test.ts b/cli/src/commands/Command.test.ts deleted file mode 100644 index a92882da..00000000 --- a/cli/src/commands/Command.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { - ConfigSource, - JsonCommandResult, - JsonConfigInfo, - JsonPluginInfo, - PluginExecutionResult -} from './Command' -import {describe, expect, it} from 'vitest' -import {parseArgs} from '@/PluginPipeline' - -describe('jsonCommandResult interface', () => { - it('should represent a successful command result with plugin details', () => { - const result: JsonCommandResult = { - success: true, - filesAffected: 5, - dirsAffected: 2, - message: 'Pipeline executed successfully', - pluginResults: [ - { - pluginName: 'GlobalMemoryInputPlugin', - kind: 'Input', - status: 'success', - duration: 120 - }, - { - pluginName: 'WarpIDEOutputPlugin', - kind: 'Output', - status: 'success', - filesWritten: 3, - duration: 250 - } - ], - errors: [] - } - - expect(result.success).toBe(true) - expect(result.filesAffected).toBe(5) - expect(result.dirsAffected).toBe(2) - expect(result.pluginResults).toHaveLength(2) - expect(result.errors).toHaveLength(0) - }) - - it('should represent a failed command result with errors', () => { - const result: JsonCommandResult = { - success: false, - filesAffected: 0, - dirsAffected: 0, - pluginResults: [ - { - pluginName: 'BrokenPlugin', - kind: 'Output', - status: 'failed', - error: 'Permission denied' - } - ], - errors: ['Pipeline failed: 1 plugin error'] - } - - expect(result.success).toBe(false) - expect(result.errors).toContain('Pipeline failed: 1 plugin error') - expect(result.pluginResults![0]!.status).toBe('failed') - expect(result.pluginResults![0]!.error).toBe('Permission denied') - }) - - it('should allow optional fields to be omitted', () => { - const minimal: JsonCommandResult = { - success: true, - filesAffected: 0, - dirsAffected: 0 - } - - expect(minimal.message).toBeUndefined() - expect(minimal.pluginResults).toBeUndefined() - expect(minimal.errors).toBeUndefined() - }) -}) - -describe('pluginExecutionResult interface', () => { - it('should represent a successful plugin execution', () => { - const result: PluginExecutionResult = { - pluginName: 'ClaudeCodeCLIOutputPlugin', - kind: 'Output', - status: 'success', - filesWritten: 2, - duration: 150 - } - - expect(result.pluginName).toBe('ClaudeCodeCLIOutputPlugin') - expect(result.kind).toBe('Output') - expect(result.status).toBe('success') - expect(result.filesWritten).toBe(2) - }) - - it('should represent a skipped plugin', () => { - const result: PluginExecutionResult = { - pluginName: 'SkippedPlugin', - kind: 'Input', - status: 'skipped' - } - - expect(result.status).toBe('skipped') - expect(result.filesWritten).toBeUndefined() - expect(result.error).toBeUndefined() - expect(result.duration).toBeUndefined() - }) -}) - -describe('jsonConfigInfo interface', () => { - it('should represent config with multiple sources', () => { - const configInfo: JsonConfigInfo = { - merged: { - logLevel: 'info' - }, - sources: [ - { - path: '/home/user/.aindex/.tnmsc.json', - layer: 'global', - config: {logLevel: 'debug'} - }, - { - path: '/project/.tnmsc.json', - layer: 'cwd', - config: {logLevel: 'info'} - } - ] - } - - expect(configInfo.sources).toHaveLength(2) - expect(configInfo.sources[0]!.layer).toBe('global') - expect(configInfo.sources[1]!.layer).toBe('cwd') - }) -}) - -describe('jsonPluginInfo interface', () => { - it('should represent a plugin with dependencies', () => { - const pluginInfo: JsonPluginInfo = { - name: 'WarpIDEOutputPlugin', - kind: 'Output', - description: 'Warp IDE output plugin', - dependencies: ['GlobalMemoryInputPlugin', 'SkillInputPlugin'] - } - - expect(pluginInfo.kind).toBe('Output') - expect(pluginInfo.dependencies).toHaveLength(2) - }) - - it('should represent a plugin with no dependencies', () => { - const pluginInfo: JsonPluginInfo = { - name: 'GlobalMemoryInputPlugin', - kind: 'Input', - description: 'Global memory input plugin', - dependencies: [] - } - - expect(pluginInfo.dependencies).toHaveLength(0) - }) -}) - -describe('configSource interface', () => { - it('should support all four layer types', () => { - const layers: ConfigSource['layer'][] = ['programmatic', 'cwd', 'global', 'default'] - - for (const layer of layers) { - const source: ConfigSource = { - path: `/some/path`, - layer, - config: {} - } - expect(source.layer).toBe(layer) - } - }) -}) - -describe('parseArgs --json flag', () => { - it('should parse --json long flag', () => { - const result = parseArgs(['execute', '--json']) - - expect(result.jsonFlag).toBe(true) - }) - - it('should parse -j short flag', () => { - const result = parseArgs(['execute', '-j']) - - expect(result.jsonFlag).toBe(true) - }) - - it('should default jsonFlag to false when not provided', () => { - const result = parseArgs(['execute']) - - expect(result.jsonFlag).toBe(false) - }) - - it('should combine --json with other flags', () => { - const result = parseArgs(['execute', '--json', '--dry-run']) - - expect(result.jsonFlag).toBe(true) - expect(result.dryRun).toBe(true) - }) - - it('should combine -j with other short flags', () => { - const result = parseArgs(['clean', '-jn']) - - expect(result.jsonFlag).toBe(true) - expect(result.dryRun).toBe(true) - }) - - it('should not treat --json as unknown', () => { - const result = parseArgs(['--json']) - - expect(result.jsonFlag).toBe(true) - expect(result.unknown).not.toContain('--json') - }) -}) diff --git a/cli/src/commands/ConfigShowCommand.test.ts b/cli/src/commands/ConfigShowCommand.test.ts deleted file mode 100644 index 03801583..00000000 --- a/cli/src/commands/ConfigShowCommand.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type {CollectedInputContext, OutputCleanContext, OutputPlugin, OutputWriteContext, PluginOptions} from '@truenine/plugin-shared' -import type {CommandContext} from './Command' -import * as nodeFs from 'node:fs' -import * as nodePath from 'node:path' -import process from 'node:process' -import {createLogger} from '@truenine/plugin-shared' -import * as fastGlob from 'fast-glob' -import {describe, expect, it, vi} from 'vitest' -import {parseArgs, resolveCommand} from '@/PluginPipeline' -import {ConfigShowCommand} from './ConfigShowCommand' - -const mockLogger = createLogger('test', 'silent') - -const mockUserConfigOptions: Required = { - workspaceDir: '/test/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'} - }, - fastCommandSeriesOptions: {}, - plugins: [], - logLevel: 'error' -} - -function createMockCommandContext(outputPlugins: readonly OutputPlugin[] = []): CommandContext { - const collectedInputContext: CollectedInputContext = { - projects: [], - globalMemory: void 0, - skills: [], - fastCommands: [], - subAgents: [], - projectPrompts: [], - ideConfigs: [], - aiAgentIgnoreConfigs: [] - } - - return { - logger: mockLogger, - outputPlugins, - collectedInputContext, - userConfigOptions: mockUserConfigOptions, - createCleanContext: (dryRun: boolean): OutputCleanContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun - }), - createWriteContext: (dryRun: boolean): OutputWriteContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun, - registeredPluginNames: outputPlugins.map(p => p.name) - }) - } -} - -describe('configShowCommand', () => { - it('should have name "config-show"', () => { - const command = new ConfigShowCommand() - expect(command.name).toBe('config-show') - }) - - it('should write JSON to stdout and return success', async () => { - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const command = new ConfigShowCommand() - const ctx = createMockCommandContext() - const result = await command.execute(ctx) - - expect(result.success).toBe(true) - expect(result.filesAffected).toBe(0) - expect(result.dirsAffected).toBe(0) - - expect(stdoutWriteSpy).toHaveBeenCalledOnce() - - const writtenData = stdoutWriteSpy.mock.calls[0]![0] as string - expect(writtenData.endsWith('\n')).toBe(true) - - const parsed = JSON.parse(writtenData.trim()) - expect(parsed).toHaveProperty('merged') - expect(parsed).toHaveProperty('sources') - expect(Array.isArray(parsed.sources)).toBe(true) - - stdoutWriteSpy.mockRestore() - }) - - it('should output valid JsonConfigInfo structure', async () => { - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const command = new ConfigShowCommand() - await command.execute(createMockCommandContext()) - - const writtenData = stdoutWriteSpy.mock.calls[0]![0] as string - const parsed = JSON.parse(writtenData.trim()) - - expect(typeof parsed.merged).toBe('object') - expect(Array.isArray(parsed.sources)).toBe(true) - - for (const source of parsed.sources) { - expect(source).toHaveProperty('path') - expect(source).toHaveProperty('layer') - expect(source).toHaveProperty('config') - expect(['programmatic', 'cwd', 'global', 'default']).toContain(source.layer) - } - - stdoutWriteSpy.mockRestore() - }) - - it('should include message in result', async () => { - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const command = new ConfigShowCommand() - const result = await command.execute(createMockCommandContext()) - - expect(result.message).toMatch(/Configuration displayed/) - - stdoutWriteSpy.mockRestore() - }) -}) - -describe('parseArgs --show flag', () => { - it('should parse --show long flag', () => { - const result = parseArgs(['config', '--show']) - expect(result.showFlag).toBe(true) - }) - - it('should default showFlag to false when not provided', () => { - const result = parseArgs(['config']) - expect(result.showFlag).toBe(false) - }) - - it('should combine --show with --json', () => { - const result = parseArgs(['config', '--show', '--json']) - expect(result.showFlag).toBe(true) - expect(result.jsonFlag).toBe(true) - expect(result.subcommand).toBe('config') - }) - - it('should not treat --show as unknown', () => { - const result = parseArgs(['config', '--show']) - expect(result.unknown).not.toContain('--show') - }) -}) - -describe('resolveCommand for config --show', () => { - it('should resolve to ConfigShowCommand when config --show is used', () => { - const args = parseArgs(['config', '--show']) - const command = resolveCommand(args) - expect(command.name).toBe('config-show') - }) - - it('should resolve to ConfigShowCommand when config --show --json is used', () => { - const args = parseArgs(['config', '--show', '--json']) - const command = resolveCommand(args) - expect(command.name).toBe('config-show') - }) - - it('should still resolve to ConfigCommand when config key=value is used', () => { - const args = parseArgs(['config', 'logLevel=debug']) - const command = resolveCommand(args) - expect(command.name).toBe('config') - }) -}) diff --git a/cli/src/commands/ExecuteCommand.test.ts b/cli/src/commands/ExecuteCommand.test.ts deleted file mode 100644 index b09c9e1d..00000000 --- a/cli/src/commands/ExecuteCommand.test.ts +++ /dev/null @@ -1,605 +0,0 @@ -import type {CollectedInputContext, OutputCleanContext, OutputPlugin, OutputWriteContext, PluginOptions, WriteResults} from '@truenine/plugin-shared' -import type {CommandContext} from './Command' -import * as nodeFs from 'node:fs' -import * as nodePath from 'node:path' -import {createLogger, PluginKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import * as fastGlob from 'fast-glob' -import {describe, expect, it, vi} from 'vitest' -import {ExecuteCommand} from './ExecuteCommand' - -const mockLogger = createLogger('test', 'error') // Mock logger - -const mockUserConfigOptions: Required = { // Mock user config options - workspaceDir: '/test/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'} - }, - fastCommandSeriesOptions: {}, - plugins: [], - logLevel: 'error' -} - -function createMockRelativePath(pathStr: string) { // Helper to create mock RelativePath - return { - pathKind: 0, - path: pathStr, - basePath: '/test', - getDirectoryName: () => pathStr, - getAbsolutePath: () => `/test/${pathStr}` - } -} - -function createMockOutputPlugin( // Helper to create mock output plugin with tracking - name: string, - files: string[] = [], - dirs: string[] = [] -): OutputPlugin & {operationOrder: string[]} { - const operationOrder: string[] = [] - - return { - type: PluginKind.Output, - name, - log: mockLogger, - operationOrder, - registerProjectOutputFiles: vi.fn(async () => { - operationOrder.push(`${name}:registerProjectOutputFiles`) - return files.map(f => createMockRelativePath(f)) - }), - registerProjectOutputDirs: vi.fn(async () => { - operationOrder.push(`${name}:registerProjectOutputDirs`) - return dirs.map(d => createMockRelativePath(d)) - }), - registerGlobalOutputFiles: vi.fn(async () => []), - registerGlobalOutputDirs: vi.fn(async () => []), - canCleanProject: vi.fn(async () => true), - canCleanGlobal: vi.fn(async () => true), - canWrite: vi.fn(async () => true), - writeProjectOutputs: vi.fn(async (): Promise => { - operationOrder.push(`${name}:writeProjectOutputs`) - return {files: [], dirs: []} - }), - writeGlobalOutputs: vi.fn(async (): Promise => { - operationOrder.push(`${name}:writeGlobalOutputs`) - return {files: [], dirs: []} - }) - } -} - -function createMockCommandContext( // Helper to create mock command context - outputPlugins: readonly OutputPlugin[] -): CommandContext { - const collectedInputContext: CollectedInputContext = { - projects: [], - globalMemory: void 0, - skills: [], - fastCommands: [], - subAgents: [], - projectPrompts: [], - ideConfigs: [], - aiAgentIgnoreConfigs: [] - } - - return { - logger: mockLogger, - outputPlugins, - collectedInputContext, - userConfigOptions: mockUserConfigOptions, - createCleanContext: (dryRun: boolean): OutputCleanContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun - }), - createWriteContext: (dryRun: boolean): OutputWriteContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun, - registeredPluginNames: outputPlugins.map(p => p.name) - }) - } -} - -describe('executeCommand', () => { - describe('pre-cleanup execution order', () => { - const pluginNameGen = fc.string({minLength: 2, maxLength: 10, unit: 'grapheme-ascii'}) // Generator for plugin names - ensure they start with letter and are unique - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const fileNameGen = fc.string({minLength: 2, maxLength: 20, unit: 'grapheme-ascii'}) // Generator for file names - .filter(s => /^[a-z][\w.-]*$/i.test(s)) - - it('should complete cleanup before write operations for any plugin configuration', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(pluginNameGen, {minLength: 1, maxLength: 3}), - fc.array(fileNameGen, {minLength: 1, maxLength: 2}), - async (pluginNames, fileNames) => { - const uniqueNames = [...new Set(pluginNames)] // Ensure unique plugin names - if (uniqueNames.length === 0) return - - const globalOperationOrder: string[] = [] // Track global operation order across all plugins - - const plugins = uniqueNames.map(name => { // Create plugins with tracking - const plugin = createMockOutputPlugin(name, fileNames) - plugin.registerProjectOutputFiles = vi.fn(async () => { // Override to track global order - globalOperationOrder.push(`cleanup:${name}`) - return fileNames.map(f => createMockRelativePath(f)) - }) - plugin.writeProjectOutputs = vi.fn(async () => { - globalOperationOrder.push(`write:${name}`) - return {files: [], dirs: []} - }) - return plugin - }) - - const ctx = createMockCommandContext(plugins) - const command = new ExecuteCommand() - - await command.execute(ctx) - - const cleanupIndices = globalOperationOrder // Find the last cleanup operation and first write operation - .map((op, i) => op.startsWith('cleanup:') ? i : -1) - .filter(i => i >= 0) - - const writeIndices = globalOperationOrder - .map((op, i) => op.startsWith('write:') ? i : -1) - .filter(i => i >= 0) - - if (cleanupIndices.length <= 0 && writeIndices.length > 0) return // All cleanup operations should complete before any write operation - - const lastCleanupIndex = Math.max(...cleanupIndices) - const firstWriteIndex = Math.min(...writeIndices) - expect(lastCleanupIndex).toBeLessThan(firstWriteIndex) - } - ), - {numRuns: 100} - ) - }) - - it('should execute cleanup before write for single plugin', async () => { // Unit test for specific scenario - const operationOrder: string[] = [] - - const plugin: OutputPlugin = { - type: PluginKind.Output, - name: 'test-plugin', - log: mockLogger, - registerProjectOutputFiles: vi.fn(async () => { - operationOrder.push('cleanup:registerFiles') - return [] - }), - registerProjectOutputDirs: vi.fn(async () => { - operationOrder.push('cleanup:registerDirs') - return [] - }), - registerGlobalOutputFiles: vi.fn(async () => []), - registerGlobalOutputDirs: vi.fn(async () => []), - canCleanProject: vi.fn(async () => true), - canCleanGlobal: vi.fn(async () => true), - canWrite: vi.fn(async () => true), - writeProjectOutputs: vi.fn(async () => { - operationOrder.push('write:project') - return {files: [], dirs: []} - }), - writeGlobalOutputs: vi.fn(async () => { - operationOrder.push('write:global') - return {files: [], dirs: []} - }) - } - - const ctx = createMockCommandContext([plugin]) - const command = new ExecuteCommand() - - await command.execute(ctx) - - const cleanupOps = operationOrder.filter(op => op.startsWith('cleanup:')) // Verify cleanup operations happen before write operations - const writeOps = operationOrder.filter(op => op.startsWith('write:')) - - expect(cleanupOps.length).toBeGreaterThan(0) - expect(writeOps.length).toBeGreaterThan(0) - - const lastCleanupIndex = operationOrder.lastIndexOf(cleanupOps.at(-1)) - const firstWriteIndex = operationOrder.indexOf(writeOps[0]) - - expect(lastCleanupIndex).toBeLessThan(firstWriteIndex) - }) - }) -}) - -describe('cleanupUtils', () => { - describe('cleanup respects plugin registration', () => { - const filePathGen = fc.string({minLength: 2, maxLength: 20, unit: 'grapheme-ascii'}) // Generator for file paths - ensure unique paths - .filter(s => /^[a-z][\w.-]*$/i.test(s)) - - it('should only collect files registered by enabled plugins', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(filePathGen, {minLength: 1, maxLength: 3}), - fc.array(filePathGen, {minLength: 1, maxLength: 3}), - async (plugin1Files, plugin2Files) => { - const plugin1 = createMockOutputPlugin('plugin1', plugin1Files) // Create two plugins with different registered files - const plugin2 = createMockOutputPlugin('plugin2', plugin2Files) - - const permissions = new Map([ // Create permissions map - both plugins allowed - ['plugin1', {project: true, global: true}], - ['plugin2', {project: true, global: true}] - ]) - - const collectedInputContext: CollectedInputContext = { - projects: [], - globalMemory: void 0, - skills: [], - fastCommands: [], - subAgents: [], - projectPrompts: [], - ideConfigs: [], - aiAgentIgnoreConfigs: [] - } - - const cleanCtx: OutputCleanContext = { - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun: false - } - - const {collectDeletionTargets} = await import('./CleanupUtils') // Import the function to test - - const {filesToDelete} = await collectDeletionTargets([plugin1, plugin2], permissions, cleanCtx) - - const allRegisteredFiles = new Set([ // All collected files should be from registered plugins - ...plugin1Files.map(f => `/test/${f}`), - ...plugin2Files.map(f => `/test/${f}`) - ]) - - for (const file of filesToDelete) expect(allRegisteredFiles.has(file)).toBe(true) - } - ), - {numRuns: 100} - ) - }) - - it('should not collect files from plugins without permission', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(filePathGen, {minLength: 1, maxLength: 3}) // Generate unique file sets to avoid overlap - .map(files => files.map(f => `allowed_${f}`)), - fc.array(filePathGen, {minLength: 1, maxLength: 3}) - .map(files => files.map(f => `denied_${f}`)), - async (allowedFiles, deniedFiles) => { - const allowedPlugin = createMockOutputPlugin('allowed', allowedFiles) // Create two plugins with non-overlapping files - const deniedPlugin = createMockOutputPlugin('denied', deniedFiles) - - const permissions = new Map([ // Only allow first plugin - ['allowed', {project: true, global: true}], - ['denied', {project: false, global: false}] - ]) - - const collectedInputContext: CollectedInputContext = { - projects: [], - globalMemory: void 0, - skills: [], - fastCommands: [], - subAgents: [], - projectPrompts: [], - ideConfigs: [], - aiAgentIgnoreConfigs: [] - } - - const cleanCtx: OutputCleanContext = { - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun: false - } - - const {collectDeletionTargets} = await import('./CleanupUtils') - - const {filesToDelete} = await collectDeletionTargets([allowedPlugin, deniedPlugin], permissions, cleanCtx) - - const deniedFilePaths = new Set(deniedFiles.map(f => `/test/${f}`)) // Files from denied plugin should not be in the list - for (const file of filesToDelete) expect(deniedFilePaths.has(file)).toBe(false) - - const allowedFilePaths = new Set(allowedFiles.map(f => `/test/${f}`)) // All files should be from allowed plugin - for (const file of filesToDelete) expect(allowedFilePaths.has(file)).toBe(true) - } - ), - {numRuns: 100} - ) - }) - - it('should only delete files from plugins with project permission', async () => { // Unit test for specific scenario - const allowedPlugin = createMockOutputPlugin('allowed', ['file1.txt', 'file2.txt']) - const deniedPlugin = createMockOutputPlugin('denied', ['file3.txt', 'file4.txt']) - - const permissions = new Map([ - ['allowed', {project: true, global: false}], - ['denied', {project: false, global: false}] - ]) - - const collectedInputContext: CollectedInputContext = { - projects: [], - globalMemory: void 0, - skills: [], - fastCommands: [], - subAgents: [], - projectPrompts: [], - ideConfigs: [], - aiAgentIgnoreConfigs: [] - } - - const cleanCtx: OutputCleanContext = { - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun: false - } - - const {collectDeletionTargets} = await import('./CleanupUtils') - - const {filesToDelete} = await collectDeletionTargets([allowedPlugin, deniedPlugin], permissions, cleanCtx) - - expect(filesToDelete).toContain('/test/file1.txt') - expect(filesToDelete).toContain('/test/file2.txt') - expect(filesToDelete).not.toContain('/test/file3.txt') - expect(filesToDelete).not.toContain('/test/file4.txt') - }) - }) -}) - -describe('dryRunOutputCommand', () => { - describe('dry-run skips actual operations', () => { - const filePathGen = fc.string({minLength: 2, maxLength: 20, unit: 'grapheme-ascii'}) // Generator for file paths - .filter(s => /^[a-z][\w.-]*$/i.test(s)) - - it('should not perform actual file operations in dry-run mode', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(filePathGen, {minLength: 1, maxLength: 3}), - async fileNames => { - const fsOperations: string[] = [] // Track actual file system operations - - const mockFs = { // Create mock fs module that tracks operations - existsSync: vi.fn(() => { - fsOperations.push('existsSync') - return true - }), - unlinkSync: vi.fn(() => fsOperations.push('unlinkSync')), - rmSync: vi.fn(() => fsOperations.push('rmSync')), - writeFileSync: vi.fn(() => fsOperations.push('writeFileSync')), - mkdirSync: vi.fn(() => fsOperations.push('mkdirSync')) - } - - const plugin: OutputPlugin = { // Create plugin that would write files - type: PluginKind.Output, - name: 'test-plugin', - log: mockLogger, - registerProjectOutputFiles: vi.fn(async () => - fileNames.map(f => createMockRelativePath(f))), - registerProjectOutputDirs: vi.fn(async () => []), - registerGlobalOutputFiles: vi.fn(async () => []), - registerGlobalOutputDirs: vi.fn(async () => []), - canCleanProject: vi.fn(async () => true), - canCleanGlobal: vi.fn(async () => true), - canWrite: vi.fn(async () => true), - writeProjectOutputs: vi.fn(async (ctx: OutputWriteContext): Promise => { - if (!ctx.dryRun) mockFs.writeFileSync() // In dry-run mode, should not perform actual writes - return { - files: fileNames.map(f => ({ - path: createMockRelativePath(f), - success: true, - skipped: ctx.dryRun - })), - dirs: [] - } - }), - writeGlobalOutputs: vi.fn(async (): Promise => ({ - files: [], - dirs: [] - })) - } - - const collectedInputContext: CollectedInputContext = { - projects: [], - globalMemory: void 0, - skills: [], - fastCommands: [], - subAgents: [], - projectPrompts: [], - ideConfigs: [], - aiAgentIgnoreConfigs: [] - } - - const dryRunWriteCtx: OutputWriteContext = { // Create dry-run write context - logger: mockLogger, - fs: mockFs as any, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun: true, - registeredPluginNames: ['test-plugin'] - } - - const {DryRunOutputCommand} = await import('./DryRunOutputCommand') // Import DryRunOutputCommand - const command = new DryRunOutputCommand() - - const ctx: CommandContext = { // Create context that returns dry-run contexts - logger: mockLogger, - outputPlugins: [plugin], - collectedInputContext, - userConfigOptions: mockUserConfigOptions, - createCleanContext: (): OutputCleanContext => ({ - ...dryRunWriteCtx, - dryRun: true - }), - createWriteContext: (): OutputWriteContext => ({ - ...dryRunWriteCtx, - dryRun: true - }) - } - - await command.execute(ctx) - - expect(fsOperations).not.toContain('unlinkSync') // In dry-run mode, no actual file operations should occur - expect(fsOperations).not.toContain('rmSync') - expect(fsOperations).not.toContain('writeFileSync') - } - ), - {numRuns: 100} - ) - }) - - it('should mark files as skipped in dry-run mode', async () => { // Unit test for specific scenario - const plugin: OutputPlugin = { - type: PluginKind.Output, - name: 'test-plugin', - log: mockLogger, - registerProjectOutputFiles: vi.fn(async () => []), - registerProjectOutputDirs: vi.fn(async () => []), - registerGlobalOutputFiles: vi.fn(async () => []), - registerGlobalOutputDirs: vi.fn(async () => []), - canWrite: vi.fn(async () => true), - writeProjectOutputs: vi.fn(async (ctx: OutputWriteContext): Promise => ({ - files: [{ - path: createMockRelativePath('test.txt'), - success: true, - skipped: ctx.dryRun - }], - dirs: [] - })), - writeGlobalOutputs: vi.fn(async (): Promise => ({ - files: [], - dirs: [] - })) - } - - const collectedInputContext: CollectedInputContext = { - projects: [], - globalMemory: void 0, - skills: [], - fastCommands: [], - subAgents: [], - projectPrompts: [], - ideConfigs: [], - aiAgentIgnoreConfigs: [] - } - - const ctx: CommandContext = { - logger: mockLogger, - outputPlugins: [plugin], - collectedInputContext, - userConfigOptions: mockUserConfigOptions, - createCleanContext: (): OutputCleanContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun: true - }), - createWriteContext: (): OutputWriteContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun: true, - registeredPluginNames: ['test-plugin'] - }) - } - - const {DryRunOutputCommand} = await import('./DryRunOutputCommand') - const command = new DryRunOutputCommand() - - const result = await command.execute(ctx) - - expect(result.success).toBe(true) - expect(result.message).toContain('Dry-run') - }) - - it('should not call ExecuteCommand pre-cleanup in dry-run mode', async () => { // It directly uses createWriteContext(true) which sets dryRun to true // DryRunOutputCommand is a separate command that doesn't call ExecuteCommand // This test verifies that DryRunOutputCommand doesn't perform cleanup - const cleanupCalled = {value: false} - - const plugin: OutputPlugin = { - type: PluginKind.Output, - name: 'test-plugin', - log: mockLogger, - registerProjectOutputFiles: vi.fn(async () => { - cleanupCalled.value = true - return [] - }), - registerProjectOutputDirs: vi.fn(async () => []), - registerGlobalOutputFiles: vi.fn(async () => []), - registerGlobalOutputDirs: vi.fn(async () => []), - canWrite: vi.fn(async () => true), - writeProjectOutputs: vi.fn(async (): Promise => ({ - files: [], - dirs: [] - })), - writeGlobalOutputs: vi.fn(async (): Promise => ({ - files: [], - dirs: [] - })) - } - - const collectedInputContext2: CollectedInputContext = { - projects: [], - globalMemory: void 0, - skills: [], - fastCommands: [], - subAgents: [], - projectPrompts: [], - ideConfigs: [], - aiAgentIgnoreConfigs: [] - } - - const ctx2: CommandContext = { - logger: mockLogger, - outputPlugins: [plugin], - collectedInputContext: collectedInputContext2, - userConfigOptions: mockUserConfigOptions, - createCleanContext: (): OutputCleanContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext: collectedInputContext2, - dryRun: true - }), - createWriteContext: (): OutputWriteContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext: collectedInputContext2, - dryRun: true, - registeredPluginNames: ['test-plugin'] - }) - } - - const {DryRunOutputCommand} = await import('./DryRunOutputCommand') - const command = new DryRunOutputCommand() - - cleanupCalled.value = false // Reset the flag - - await command.execute(ctx2) - }) // The key is that no actual deletion happens // Note: It may still be called for other purposes, but not for actual cleanup // (which is part of cleanup collection) // DryRunOutputCommand should not call registerProjectOutputFiles - }) -}) diff --git a/cli/src/commands/HelpCommand.test.ts b/cli/src/commands/HelpCommand.test.ts deleted file mode 100644 index 0dff71f5..00000000 --- a/cli/src/commands/HelpCommand.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type {ILogger} from '@truenine/plugin-shared' -import {describe, expect, it, vi} from 'vitest' -import {HelpCommand} from './HelpCommand' - -function createMockLogger(): ILogger { - return { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn() - } -} - -describe('helpCommand', () => { - describe('help text content', () => { - it('should list all subcommands', async () => { - const mockLogger = createMockLogger() - - const command = new HelpCommand() - await command.execute({logger: mockLogger} as any) - - const helpText = (mockLogger.info as ReturnType).mock.calls[0]?.[0] as string - - expect(helpText).toContain('help') // Verify all subcommands are listed (Requirements 8.1) - expect(helpText).toContain('init') - expect(helpText).toContain('dry-run') - expect(helpText).toContain('clean') - }) - - it('should list all log level options', async () => { - const mockLogger = createMockLogger() - - const command = new HelpCommand() - await command.execute({logger: mockLogger} as any) - - const helpText = (mockLogger.info as ReturnType).mock.calls[0]?.[0] as string - - expect(helpText).toContain('--trace') // Verify all log level options are listed (Requirements 8.2) - expect(helpText).toContain('--debug') - expect(helpText).toContain('--info') - expect(helpText).toContain('--warn') - expect(helpText).toContain('--error') - }) - - it('should show clean dry-run options', async () => { - const mockLogger = createMockLogger() - - const command = new HelpCommand() - await command.execute({logger: mockLogger} as any) - - const helpText = (mockLogger.info as ReturnType).mock.calls[0]?.[0] as string - - expect(helpText).toContain('-n') // Verify clean options are shown (Requirements 8.3) - expect(helpText).toContain('--dry-run') - expect(helpText).toContain('clean --dry-run') - }) - - it('should include usage examples', async () => { - const mockLogger = createMockLogger() - - const command = new HelpCommand() - await command.execute({logger: mockLogger} as any) - - const helpText = (mockLogger.info as ReturnType).mock.calls[0]?.[0] as string - - expect(helpText).toContain('USAGE:') // Verify examples are included (Requirements 8.4) - expect(helpText).toContain('tnmsc help') - expect(helpText).toContain('tnmsc init') - expect(helpText).toContain('tnmsc dry-run') - expect(helpText).toContain('tnmsc clean') - }) - - it('should return success result', async () => { - const mockLogger = createMockLogger() - - const command = new HelpCommand() - const result = await command.execute({logger: mockLogger} as any) - - expect(result.success).toBe(true) - expect(result.filesAffected).toBe(0) - expect(result.dirsAffected).toBe(0) - }) - }) -}) diff --git a/cli/src/commands/JsonOutput.property.test.ts b/cli/src/commands/JsonOutput.property.test.ts deleted file mode 100644 index c2b49e08..00000000 --- a/cli/src/commands/JsonOutput.property.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { - CommandResult, - ConfigSource, - JsonCommandResult, - JsonConfigInfo, - JsonPluginInfo, - PluginExecutionResult -} from './Command' -/** - * Property-based tests for CLI JSON serialization/deserialization round-trip. - * - * Feature: tauri-ui-module, Property 1: CLI JSON 输出序列化/反序列化 round-trip - * - * **Validates: Requirements 2.3, 2.4, 2.5, 7.3, 7.4** - * - * For any valid CommandResult / JsonCommandResult / PluginExecutionResult / - * JsonConfigInfo / JsonPluginInfo, serializing to JSON and deserializing - * should produce an equivalent data structure with all fields preserved. - */ -import * as fc from 'fast-check' - -import {describe, expect, it} from 'vitest' -import {toJsonCommandResult} from './JsonOutputCommand' // Arbitraries — smart generators constrained to the valid input space - -/** Generate a valid PluginExecutionResult */ -const arbPluginExecutionResult: fc.Arbitrary = fc.record({ - pluginName: fc.string({minLength: 1, maxLength: 50}), - kind: fc.constantFrom('Input' as const, 'Output' as const), - status: fc.constantFrom('success' as const, 'failed' as const, 'skipped' as const), - filesWritten: fc.option(fc.nat({max: 10000}), {nil: void 0}), - error: fc.option(fc.string({minLength: 0, maxLength: 200}), {nil: void 0}), - duration: fc.option(fc.double({min: 0, max: 1e6, noNaN: true, noDefaultInfinity: true}), {nil: void 0}) -}) - -/** Generate a valid JsonCommandResult */ -const arbJsonCommandResult: fc.Arbitrary = fc.record({ - success: fc.boolean(), - filesAffected: fc.nat({max: 100000}), - dirsAffected: fc.nat({max: 10000}), - message: fc.option(fc.string({minLength: 0, maxLength: 200}), {nil: void 0}), - pluginResults: fc.option( - fc.array(arbPluginExecutionResult, {minLength: 0, maxLength: 10}), - {nil: void 0} - ), - errors: fc.option( - fc.array(fc.string({minLength: 0, maxLength: 200}), {minLength: 0, maxLength: 10}), - {nil: void 0} - ) -}) - -/** Generate a valid ConfigSource */ -const arbConfigSource: fc.Arbitrary = fc.record({ - path: fc.string({minLength: 1, maxLength: 100}), - layer: fc.constantFrom('programmatic' as const, 'cwd' as const, 'global' as const, 'default' as const), - config: fc.record({ - workspaceDir: fc.option(fc.string({minLength: 1, maxLength: 80}), {nil: void 0}), - logLevel: fc.option( - fc.constantFrom('trace' as const, 'debug' as const, 'info' as const, 'warn' as const, 'error' as const), - {nil: void 0} - ) - }) -}) - -/** Generate a valid JsonConfigInfo */ -const arbJsonConfigInfo: fc.Arbitrary = fc.record({ - merged: fc.record({ - workspaceDir: fc.option(fc.string({minLength: 1, maxLength: 80}), {nil: void 0}), - logLevel: fc.option( - fc.constantFrom('trace' as const, 'debug' as const, 'info' as const, 'warn' as const, 'error' as const), - {nil: void 0} - ) - }), - sources: fc.array(arbConfigSource, {minLength: 0, maxLength: 5}) -}) - -/** Generate a valid JsonPluginInfo */ -const arbJsonPluginInfo: fc.Arbitrary = fc.record({ - name: fc.string({minLength: 1, maxLength: 80}), - kind: fc.constantFrom('Input' as const, 'Output' as const), - description: fc.string({minLength: 0, maxLength: 200}), - dependencies: fc.array(fc.string({minLength: 1, maxLength: 80}), {minLength: 0, maxLength: 10}) -}) - -/** Generate a valid CommandResult (base type used by toJsonCommandResult) */ -const arbCommandResult: fc.Arbitrary = fc.record({ - success: fc.boolean(), - filesAffected: fc.nat({max: 100000}), - dirsAffected: fc.nat({max: 10000}), - message: fc.option(fc.string({minLength: 0, maxLength: 200}), {nil: void 0}) -}) // Property tests - -const NUM_RUNS = 100 - -describe('property 1: CLI JSON 输出序列化/反序列化 round-trip', () => { - it('jsonCommandResult round-trip: JSON.stringify → JSON.parse preserves all fields', () => { - fc.assert( - fc.property(arbJsonCommandResult, original => { - const serialized = JSON.stringify(original) - const deserialized = JSON.parse(serialized) as JsonCommandResult - expect(deserialized).toEqual(original) - }), - {numRuns: NUM_RUNS} - ) - }) - - it('pluginExecutionResult round-trip: JSON.stringify → JSON.parse preserves all fields', () => { - fc.assert( - fc.property(arbPluginExecutionResult, original => { - const serialized = JSON.stringify(original) - const deserialized = JSON.parse(serialized) as PluginExecutionResult - expect(deserialized).toEqual(original) - }), - {numRuns: NUM_RUNS} - ) - }) - - it('jsonConfigInfo round-trip: JSON.stringify → JSON.parse preserves all fields', () => { - fc.assert( - fc.property(arbJsonConfigInfo, original => { - const serialized = JSON.stringify(original) - const deserialized = JSON.parse(serialized) as JsonConfigInfo - expect(deserialized).toEqual(original) - }), - {numRuns: NUM_RUNS} - ) - }) - - it('jsonPluginInfo round-trip: JSON.stringify → JSON.parse preserves all fields', () => { - fc.assert( - fc.property(arbJsonPluginInfo, original => { - const serialized = JSON.stringify(original) - const deserialized = JSON.parse(serialized) as JsonPluginInfo - expect(deserialized).toEqual(original) - }), - {numRuns: NUM_RUNS} - ) - }) - - it('toJsonCommandResult preserves all base fields from CommandResult', () => { - fc.assert( - fc.property(arbCommandResult, commandResult => { - const jsonResult = toJsonCommandResult(commandResult) - - expect(jsonResult.success).toBe(commandResult.success) // Base fields must be preserved - expect(jsonResult.filesAffected).toBe(commandResult.filesAffected) - expect(jsonResult.dirsAffected).toBe(commandResult.dirsAffected) - - if (commandResult.message != null) { // message: preserved when present, absent when undefined - expect(jsonResult.message).toBe(commandResult.message) - } else expect(jsonResult.message).toBeUndefined() - - expect(jsonResult.pluginResults).toEqual([]) // pluginResults and errors initialised as empty arrays - expect(jsonResult.errors).toEqual([]) - }), - {numRuns: NUM_RUNS} - ) - }) - - it('toJsonCommandResult output survives JSON round-trip', () => { - fc.assert( - fc.property(arbCommandResult, commandResult => { - const jsonResult = toJsonCommandResult(commandResult) - const serialized = JSON.stringify(jsonResult) - const deserialized = JSON.parse(serialized) as JsonCommandResult - expect(deserialized).toEqual(jsonResult) - }), - {numRuns: NUM_RUNS} - ) - }) -}) diff --git a/cli/src/commands/JsonOutputCommand.test.ts b/cli/src/commands/JsonOutputCommand.test.ts deleted file mode 100644 index ca2cbd6e..00000000 --- a/cli/src/commands/JsonOutputCommand.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type {CollectedInputContext, OutputCleanContext, OutputPlugin, OutputWriteContext, PluginOptions} from '@truenine/plugin-shared' -import type {CommandContext, CommandResult} from './Command' -import * as nodeFs from 'node:fs' -import * as nodePath from 'node:path' -import process from 'node:process' -import {createLogger} from '@truenine/plugin-shared' -import * as fastGlob from 'fast-glob' -import {describe, expect, it, vi} from 'vitest' -import {JsonOutputCommand, toJsonCommandResult} from './JsonOutputCommand' - -const mockLogger = createLogger('test', 'silent') - -const mockUserConfigOptions: Required = { - workspaceDir: '/test/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'} - }, - fastCommandSeriesOptions: {}, - plugins: [], - logLevel: 'error' -} - -function createMockCommandContext(outputPlugins: readonly OutputPlugin[] = []): CommandContext { - const collectedInputContext: CollectedInputContext = { - projects: [], - globalMemory: void 0, - skills: [], - fastCommands: [], - subAgents: [], - projectPrompts: [], - ideConfigs: [], - aiAgentIgnoreConfigs: [] - } - - return { - logger: mockLogger, - outputPlugins, - collectedInputContext, - userConfigOptions: mockUserConfigOptions, - createCleanContext: (dryRun: boolean): OutputCleanContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun - }), - createWriteContext: (dryRun: boolean): OutputWriteContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun, - registeredPluginNames: outputPlugins.map(p => p.name) - }) - } -} - -describe('toJsonCommandResult', () => { - it('should convert a successful CommandResult with all fields', () => { - const result: CommandResult = { - success: true, - filesAffected: 10, - dirsAffected: 3, - message: 'Pipeline executed successfully' - } - - const json = toJsonCommandResult(result) - - expect(json.success).toBe(true) - expect(json.filesAffected).toBe(10) - expect(json.dirsAffected).toBe(3) - expect(json.message).toBe('Pipeline executed successfully') - expect(json.pluginResults).toEqual([]) - expect(json.errors).toEqual([]) - }) - - it('should convert a failed CommandResult', () => { - const result: CommandResult = { - success: false, - filesAffected: 0, - dirsAffected: 0 - } - - const json = toJsonCommandResult(result) - - expect(json.success).toBe(false) - expect(json.filesAffected).toBe(0) - expect(json.dirsAffected).toBe(0) - expect(json.message).toBeUndefined() - expect(json.pluginResults).toEqual([]) - expect(json.errors).toEqual([]) - }) - - it('should omit message when CommandResult has no message', () => { - const result: CommandResult = { - success: true, - filesAffected: 5, - dirsAffected: 1 - } - - const json = toJsonCommandResult(result) - - expect('message' in json).toBe(false) - }) - - it('should produce valid JSON when stringified', () => { - const result: CommandResult = { - success: true, - filesAffected: 7, - dirsAffected: 2, - message: 'Done' - } - - const json = toJsonCommandResult(result) - const str = JSON.stringify(json) - const parsed = JSON.parse(str) - - expect(parsed.success).toBe(true) - expect(parsed.filesAffected).toBe(7) - expect(parsed.dirsAffected).toBe(2) - expect(parsed.message).toBe('Done') - expect(parsed.pluginResults).toEqual([]) - expect(parsed.errors).toEqual([]) - }) -}) - -describe('jsonOutputCommand', () => { - it('should set name to json:', () => { - const inner = { - name: 'execute', - execute: vi.fn(async () => ({success: true, filesAffected: 0, dirsAffected: 0})) - } - - const command = new JsonOutputCommand(inner) - - expect(command.name).toBe('json:execute') - }) - - it('should delegate execution to the inner command', async () => { - const expectedResult: CommandResult = { - success: true, - filesAffected: 5, - dirsAffected: 2, - message: 'test' - } - const inner = { - name: 'clean', - execute: vi.fn(async () => expectedResult) - } - - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const command = new JsonOutputCommand(inner) - const ctx = createMockCommandContext() - const result = await command.execute(ctx) - - expect(inner.execute).toHaveBeenCalledWith(ctx) - expect(result).toBe(expectedResult) - - stdoutWriteSpy.mockRestore() - }) - - it('should write JSON to stdout', async () => { - const inner = { - name: 'execute', - execute: vi.fn(async () => ({ - success: true, - filesAffected: 3, - dirsAffected: 1, - message: 'Pipeline done' - })) - } - - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const command = new JsonOutputCommand(inner) - await command.execute(createMockCommandContext()) - - expect(stdoutWriteSpy).toHaveBeenCalledOnce() - - const writtenData = stdoutWriteSpy.mock.calls[0]![0] as string - const parsed = JSON.parse(writtenData) - - expect(parsed.success).toBe(true) - expect(parsed.filesAffected).toBe(3) - expect(parsed.dirsAffected).toBe(1) - expect(parsed.message).toBe('Pipeline done') - expect(parsed.pluginResults).toEqual([]) - expect(parsed.errors).toEqual([]) - - stdoutWriteSpy.mockRestore() - }) - - it('should output valid JSON terminated with newline', async () => { - const inner = { - name: 'dry-run-output', - execute: vi.fn(async () => ({ - success: true, - filesAffected: 0, - dirsAffected: 0 - })) - } - - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const command = new JsonOutputCommand(inner) - await command.execute(createMockCommandContext()) - - const writtenData = stdoutWriteSpy.mock.calls[0]![0] as string - expect(writtenData.endsWith('\n')).toBe(true) - - expect(() => JSON.parse(writtenData.trim())).not.toThrow() // Should not throw when parsing (valid JSON) - - stdoutWriteSpy.mockRestore() - }) - - it('should return the original CommandResult from inner command', async () => { - const innerResult: CommandResult = { - success: false, - filesAffected: 0, - dirsAffected: 0, - message: 'Something failed' - } - const inner = { - name: 'execute', - execute: vi.fn(async () => innerResult) - } - - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const command = new JsonOutputCommand(inner) - const result = await command.execute(createMockCommandContext()) - - expect(result).toBe(innerResult) // The returned result should be the original, not the JSON version - expect(result.success).toBe(false) - expect(result.message).toBe('Something failed') - - stdoutWriteSpy.mockRestore() - }) - - it('should work with different inner command names', async () => { - for (const name of ['execute', 'clean', 'dry-run-output']) { - const inner = { - name, - execute: vi.fn(async () => ({success: true, filesAffected: 0, dirsAffected: 0})) - } - - const command = new JsonOutputCommand(inner) - expect(command.name).toBe(`json:${name}`) - } - }) -}) diff --git a/cli/src/commands/PluginsCommand.test.ts b/cli/src/commands/PluginsCommand.test.ts deleted file mode 100644 index 307342d5..00000000 --- a/cli/src/commands/PluginsCommand.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import type {CollectedInputContext, InputPlugin, OutputCleanContext, OutputPlugin, OutputWriteContext, PluginOptions} from '@truenine/plugin-shared' -import type {CommandContext, JsonPluginInfo} from './Command' -import * as nodeFs from 'node:fs' -import * as nodePath from 'node:path' -import process from 'node:process' -import {createLogger, PluginKind} from '@truenine/plugin-shared' -import * as fastGlob from 'fast-glob' -import {describe, expect, it, vi} from 'vitest' -import {parseArgs, resolveCommand} from '@/PluginPipeline' -import {PluginsCommand} from './PluginsCommand' - -const mockLogger = createLogger('test', 'silent') - -function createMockOutputPlugin(name: string, dependsOn?: readonly string[]): OutputPlugin { - return { - name, - type: PluginKind.Output, - log: mockLogger, - dependsOn, - write: vi.fn(async () => ({files: [], dirs: []})), - clean: vi.fn(async () => ({files: [], dirs: []})) - } -} - -function createMockInputPlugin(name: string, dependsOn?: readonly string[]): InputPlugin { - return { - name, - type: PluginKind.Input, - log: mockLogger, - dependsOn, - collect: vi.fn(async () => ({})) - } -} - -function createMockCommandContext( - outputPlugins: readonly OutputPlugin[] = [], - plugins: (InputPlugin | OutputPlugin)[] = [] -): CommandContext { - const collectedInputContext: CollectedInputContext = { - projects: [], - globalMemory: void 0, - skills: [], - fastCommands: [], - subAgents: [], - projectPrompts: [], - ideConfigs: [], - aiAgentIgnoreConfigs: [] - } - - const mockUserConfigOptions: Required = { - workspaceDir: '/test/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'} - }, - fastCommandSeriesOptions: {}, - plugins, - logLevel: 'error' - } - - return { - logger: mockLogger, - outputPlugins, - collectedInputContext, - userConfigOptions: mockUserConfigOptions, - createCleanContext: (dryRun: boolean): OutputCleanContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun - }), - createWriteContext: (dryRun: boolean): OutputWriteContext => ({ - logger: mockLogger, - fs: nodeFs, - path: nodePath, - glob: fastGlob, - collectedInputContext, - dryRun, - registeredPluginNames: outputPlugins.map(p => p.name) - }) - } -} - -describe('pluginsCommand', () => { - it('should have name "plugins"', () => { - const command = new PluginsCommand() - expect(command.name).toBe('plugins') - }) - - it('should write JSON array to stdout and return success', async () => { - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const inputPlugin = createMockInputPlugin('TestInputPlugin') - const outputPlugin = createMockOutputPlugin('TestOutputPlugin') - - const command = new PluginsCommand() - const ctx = createMockCommandContext([outputPlugin], [inputPlugin, outputPlugin]) - const result = await command.execute(ctx) - - expect(result.success).toBe(true) - expect(result.filesAffected).toBe(0) - expect(result.dirsAffected).toBe(0) - - expect(stdoutWriteSpy).toHaveBeenCalledOnce() - - const writtenData = stdoutWriteSpy.mock.calls[0]![0] as string - expect(writtenData.endsWith('\n')).toBe(true) - - const parsed = JSON.parse(writtenData.trim()) as JsonPluginInfo[] - expect(Array.isArray(parsed)).toBe(true) - expect(parsed.length).toBe(2) - - stdoutWriteSpy.mockRestore() - }) - - it('should output valid JsonPluginInfo structure for each plugin', async () => { - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const inputPlugin = createMockInputPlugin('GlobalMemoryInputPlugin', ['WorkspaceInputPlugin']) - const outputPlugin = createMockOutputPlugin('WarpIDEOutputPlugin', ['GlobalMemoryInputPlugin']) - - const command = new PluginsCommand() - await command.execute(createMockCommandContext([outputPlugin], [inputPlugin, outputPlugin])) - - const writtenData = stdoutWriteSpy.mock.calls[0]![0] as string - const parsed = JSON.parse(writtenData.trim()) as JsonPluginInfo[] - - for (const info of parsed) { - expect(info).toHaveProperty('name') - expect(info).toHaveProperty('kind') - expect(info).toHaveProperty('description') - expect(info).toHaveProperty('dependencies') - expect(['Input', 'Output']).toContain(info.kind) - expect(Array.isArray(info.dependencies)).toBe(true) - } - - const inputInfo = parsed.find(p => p.name === 'GlobalMemoryInputPlugin')! - expect(inputInfo.kind).toBe('Input') - expect(inputInfo.dependencies).toEqual(['WorkspaceInputPlugin']) - - const outputInfo = parsed.find(p => p.name === 'WarpIDEOutputPlugin')! - expect(outputInfo.kind).toBe('Output') - expect(outputInfo.dependencies).toEqual(['GlobalMemoryInputPlugin']) - - stdoutWriteSpy.mockRestore() - }) - - it('should include output plugins not in userConfigOptions.plugins', async () => { - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const inputPlugin = createMockInputPlugin('TestInput') - const extraOutputPlugin = createMockOutputPlugin('ExtraOutput') - - const command = new PluginsCommand() // inputPlugin is in plugins, extraOutputPlugin is only in outputPlugins - await command.execute(createMockCommandContext([extraOutputPlugin], [inputPlugin])) - - const writtenData = stdoutWriteSpy.mock.calls[0]![0] as string - const parsed = JSON.parse(writtenData.trim()) as JsonPluginInfo[] - - expect(parsed).toHaveLength(2) - expect(parsed.map(p => p.name)).toContain('TestInput') - expect(parsed.map(p => p.name)).toContain('ExtraOutput') - - stdoutWriteSpy.mockRestore() - }) - - it('should not duplicate plugins that appear in both lists', async () => { - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const outputPlugin = createMockOutputPlugin('SharedPlugin') - - const command = new PluginsCommand() // Same plugin in both userConfigOptions.plugins and outputPlugins - await command.execute(createMockCommandContext([outputPlugin], [outputPlugin])) - - const writtenData = stdoutWriteSpy.mock.calls[0]![0] as string - const parsed = JSON.parse(writtenData.trim()) as JsonPluginInfo[] - - const sharedCount = parsed.filter(p => p.name === 'SharedPlugin').length - expect(sharedCount).toBe(1) - - stdoutWriteSpy.mockRestore() - }) - - it('should handle empty plugin lists', async () => { - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const command = new PluginsCommand() - const result = await command.execute(createMockCommandContext([], [])) - - expect(result.success).toBe(true) - - const writtenData = stdoutWriteSpy.mock.calls[0]![0] as string - const parsed = JSON.parse(writtenData.trim()) as JsonPluginInfo[] - expect(parsed).toHaveLength(0) - - stdoutWriteSpy.mockRestore() - }) - - it('should include message with plugin count', async () => { - const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - const plugin = createMockInputPlugin('TestPlugin') - const command = new PluginsCommand() - const result = await command.execute(createMockCommandContext([], [plugin])) - - expect(result.message).toMatch(/Listed 1 plugin/) - - stdoutWriteSpy.mockRestore() - }) -}) - -describe('parseArgs plugins subcommand', () => { - it('should parse "plugins" as a valid subcommand', () => { - const result = parseArgs(['plugins']) - expect(result.subcommand).toBe('plugins') - }) - - it('should parse "plugins --json"', () => { - const result = parseArgs(['plugins', '--json']) - expect(result.subcommand).toBe('plugins') - expect(result.jsonFlag).toBe(true) - }) - - it('should not treat "plugins" as unknown command', () => { - const result = parseArgs(['plugins']) - expect(result.unknownCommand).toBeUndefined() - }) -}) - -describe('resolveCommand for plugins', () => { - it('should resolve to PluginsCommand when plugins subcommand is used', () => { - const args = parseArgs(['plugins']) - const command = resolveCommand(args) - expect(command.name).toBe('plugins') - }) - - it('should resolve to PluginsCommand when plugins --json is used', () => { - const args = parseArgs(['plugins', '--json']) - const command = resolveCommand(args) - expect(command.name).toBe('plugins') - }) -}) diff --git a/cli/src/compiler-integration.test.ts b/cli/src/compiler-integration.test.ts deleted file mode 100644 index ca26761e..00000000 --- a/cli/src/compiler-integration.test.ts +++ /dev/null @@ -1,446 +0,0 @@ -/** - * Integration tests for compiler integration with plugin pipeline. - * - * Tests the complete flow from configuration loading to MDX compilation, - * including multi-plugin scope registration and merging. - * - * @see Requirements 5.1, 5.2, 5.3, 5.5 - */ - -import type {MdxGlobalScope} from '@truenine/md-compiler/globals' -import type {CollectedInputContext, InputPluginContext, PluginOptions} from '@truenine/plugin-shared' -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, 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' - -/** - * Mock input plugin for testing scope registration - */ -class MockScopePlugin extends AbstractInputPlugin { - private readonly scopeNamespace: string - private readonly scopeValues: Record - - constructor(name: string, namespace: string, values: Record, dependsOn?: readonly string[]) { - super(name, dependsOn) - this.scopeNamespace = namespace - this.scopeValues = values - } - - collect(_ctx: InputPluginContext): Partial { - this.registerScope(this.scopeNamespace, this.scopeValues) // Register scope during collection - return {} - } -} - -describe('compiler Integration', () => { - beforeEach(() => registerBuiltInComponents()) - - afterEach(() => clearComponents()) - - describe('globalScopeCollector integration', () => { - it('should collect complete global scope with all namespaces', () => { - const collector = new GlobalScopeCollector() - const scope = collector.collect() - - expect(scope.os).toBeDefined() // Verify os namespace has required properties - expect(scope.os.platform).toBeDefined() - expect(scope.os.arch).toBeDefined() - expect(scope.os.hostname).toBeDefined() - expect(scope.os.homedir).toBeDefined() - expect(scope.os.tmpdir).toBeDefined() - expect(scope.os.type).toBeDefined() - expect(scope.os.release).toBeDefined() - expect(scope.os.shellKind).toBeDefined() - - expect(scope.env).toBeDefined() // Verify env namespace exists - expect(typeof scope.env).toBe('object') - - expect(scope.profile).toBeDefined() // Verify profile namespace exists (empty by default) - expect(typeof scope.profile).toBe('object') - - expect(scope.tool).toBeDefined() // Verify tool namespace has defaults - expect(scope.tool.websearch).toBe('web_search') - expect(scope.tool.webfetch).toBe('web_fetch') - }) - - it('should merge user config profile into global scope', () => { - const userConfig = { - profile: { - name: 'Test User', - username: 'testuser', - customField: 'custom value' - } - } - - const collector = new GlobalScopeCollector({userConfig}) - const scope = collector.collect() - - expect(scope.profile.name).toBe('Test User') - expect(scope.profile.username).toBe('testuser') - expect(scope.profile['customField']).toBe('custom value') - }) - - it('should use system default tool references (not user configurable)', () => { - const collector = new GlobalScopeCollector() // Output plugins may override these values for specific AI tools // tool is no longer user-configurable, it uses system defaults - const scope = collector.collect() - - expect(scope.tool.websearch).toBe('web_search') // System defaults should be used - expect(scope.tool.webfetch).toBe('web_fetch') - }) - }) - - describe('scopeRegistry integration', () => { - it('should merge scopes with correct priority order', () => { - const registry = new ScopeRegistry() - - const globalScope: MdxGlobalScope = { // Set global scope (lowest priority) - os: {platform: 'linux', shellKind: ShellKind.Bash}, - env: {NODE_ENV: 'test'}, - profile: {name: 'Global User'}, - tool: {websearch: 'global_search'} - } - registry.setGlobalScope(globalScope) - - registry.register('profile', {name: 'Config User', role: 'developer'}, ScopePriority.UserConfig) // Register user config scope - - registry.register('profile', {name: 'Plugin User', team: 'engineering'}, ScopePriority.PluginRegistered) // Register plugin scope - - const compileTimeScope = {profile: {name: 'Compile User'}} // Merge with compile-time scope - const merged = registry.merge(compileTimeScope) - - expect((merged['profile'] as Record)['name']).toBe('Compile User') // Compile-time should win for 'name' - expect((merged['profile'] as Record)['team']).toBe('engineering') // Plugin scope should provide 'team' - expect((merged['profile'] as Record)['role']).toBe('developer') // User config should provide 'role' - }) - - it('should deep merge nested objects', () => { - const registry = new ScopeRegistry() - - registry.register('config', { - database: {host: 'localhost', port: 5432}, - cache: {enabled: true} - }, ScopePriority.SystemDefault) - - registry.register('config', { - database: {port: 3306, name: 'mydb'} - }, ScopePriority.UserConfig) - - const merged = registry.merge() - const config = merged['config'] as Record - const database = config['database'] as Record - - expect(database['host']).toBe('localhost') // Deep merge should preserve host from first registration - expect(database['port']).toBe(3306) // Deep merge should override port from second registration - expect(database['name']).toBe('mydb') // Deep merge should add name from second registration - expect((config['cache'] as Record)['enabled']).toBe(true) // Cache should be preserved - }) - }) - - describe('mdxToMd with global scope', () => { - it('should evaluate expressions using global scope', async () => { - const globalScope: MdxGlobalScope = { - os: {platform: 'linux', arch: 'x64', shellKind: ShellKind.Bash}, - env: {NODE_ENV: 'production'}, - profile: {name: 'Test User', username: 'testuser'}, - tool: {websearch: 'web_search', webfetch: 'web_fetch'} - } - - const content = `# Hello {profile.name} - -Platform: {os.platform} -Environment: {env.NODE_ENV} -Search Tool: {tool.websearch}` - - const result = await mdxToMd(content, {globalScope}) - - expect(result).toContain('# Hello Test User') - expect(result).toContain('Platform: linux') - expect(result).toContain('Environment: production') - expect(result).toContain('Search Tool: web_search') - }) - - it('should allow custom scope to override global scope', async () => { - const globalScope: MdxGlobalScope = { - os: {platform: 'linux', shellKind: ShellKind.Bash}, - env: {}, - profile: {name: 'Global User'}, - tool: {websearch: 'global_search'} - } - - const customScope = { - profile: {name: 'Custom User'} - } - - const content = `Hello {profile.name}` - - const result = await mdxToMd(content, {globalScope, scope: customScope}) - - expect(result).toContain('Hello Custom User') // Custom scope should override global scope - }) - - it('should extract metadata while using global scope', async () => { - const globalScope: MdxGlobalScope = { - os: {platform: 'darwin', shellKind: ShellKind.Zsh}, - env: {}, - profile: {name: 'Test User'}, - tool: {} - } - - const content = `export const name = "test-skill" -export const description = "A test skill" - -# Hello {profile.name} - -This skill runs on {os.platform}.` - - const result = await mdxToMd(content, {globalScope, extractMetadata: true}) - - expect(result.content).toContain('# Hello Test User') // Content should have expressions evaluated - expect(result.content).toContain('This skill runs on darwin.') - - expect(result.metadata.fields['name']).toBe('test-skill') // Metadata should be extracted - expect(result.metadata.fields['description']).toBe('A test skill') - expect(result.metadata.source).toBe('export') - - expect(result.content).not.toContain('export const') // Export statements should be removed - }) - }) - - describe('multi-plugin scope registration', () => { - it('should collect scopes from multiple plugins', () => { - const plugin1 = new MockScopePlugin('plugin1', 'plugin1', {version: '1.0.0', name: 'Plugin One'}) - const plugin2 = new MockScopePlugin('plugin2', 'plugin2', {version: '2.0.0', name: 'Plugin Two'}) - - const logger = createLogger('test') - const ctx: InputPluginContext = { - logger, - fs, - path, - glob, - userConfigOptions: {} as Required, - dependencyContext: {} - } - - plugin1.collect(ctx) // Execute plugins - plugin2.collect(ctx) - - const scopes1 = plugin1.getRegisteredScopes() // Get registered scopes - const scopes2 = plugin2.getRegisteredScopes() - - expect(scopes1).toHaveLength(1) - expect(scopes1[0]?.namespace).toBe('plugin1') - expect(scopes1[0]?.values['version']).toBe('1.0.0') - - expect(scopes2).toHaveLength(1) - expect(scopes2[0]?.namespace).toBe('plugin2') - expect(scopes2[0]?.values['version']).toBe('2.0.0') - }) - - it('should merge scopes from multiple plugins into registry', () => { - const plugin1 = new MockScopePlugin('plugin1', 'shared', {key1: 'value1', common: 'from-plugin1'}) - const plugin2 = new MockScopePlugin('plugin2', 'shared', {key2: 'value2', common: 'from-plugin2'}) - - const logger = createLogger('test') - const ctx: InputPluginContext = { - logger, - fs, - path, - glob, - userConfigOptions: {} as Required, - dependencyContext: {} - } - - plugin1.collect(ctx) // Execute plugins - plugin2.collect(ctx) - - const registry = new ScopeRegistry() // Create registry and register scopes - - for (const {namespace, values} of plugin1.getRegisteredScopes()) registry.register(namespace, values, ScopePriority.PluginRegistered) - - for (const {namespace, values} of plugin2.getRegisteredScopes()) registry.register(namespace, values, ScopePriority.PluginRegistered) - - const merged = registry.merge() - const shared = merged['shared'] as Record - - expect(shared['key1']).toBe('value1') // Both keys should be present - expect(shared['key2']).toBe('value2') - expect(shared['common']).toBe('from-plugin2') // Later registration should override common key - }) - - it('should integrate plugin scopes with global scope in MDX compilation', async () => { - const globalScope: MdxGlobalScope = { // Create global scope - os: {platform: 'linux', shellKind: ShellKind.Bash}, - env: {NODE_ENV: 'test'}, - profile: {name: 'Test User'}, - tool: {websearch: 'search'} - } - - const registry = new ScopeRegistry() // Create registry with global scope - registry.setGlobalScope(globalScope) - - registry.register('plugin1', {version: '1.0.0'}, ScopePriority.PluginRegistered) // Register plugin scopes - registry.register('plugin2', {feature: 'enabled'}, ScopePriority.PluginRegistered) - - const mergedScope = registry.merge() // Merge all scopes - - const content = `# {profile.name}'s Dashboard // Compile MDX with merged scope - -Platform: {os.platform} -Plugin1 Version: {plugin1.version} -Plugin2 Feature: {plugin2.feature}` - - const result = await mdxToMd(content, {scope: mergedScope}) - - expect(result).toContain('# Test User\'s Dashboard') - expect(result).toContain('Platform: linux') - expect(result).toContain('Plugin1 Version: 1.0.0') - expect(result).toContain('Plugin2 Feature: enabled') - }) - }) - - describe('complete configuration to compilation flow', () => { - it('should flow from user config through scope collection to MDX compilation', async () => { - const userConfig = { // Step 1: User configuration (tool is no longer user-configurable) - profile: { - name: 'John Doe', - username: 'johndoe', - role: 'developer' - } - } - - const collector = new GlobalScopeCollector({userConfig}) // Step 2: Collect global scope - const globalScope = collector.collect() - - const registry = new ScopeRegistry() // Step 3: Create registry and set global scope - registry.setGlobalScope(globalScope) - - registry.register('myPlugin', { // Step 4: Simulate plugin scope registration - version: '3.0.0', - config: {debug: true, timeout: 5000} - }, ScopePriority.PluginRegistered) - - const mergedScope = registry.merge() // Step 5: Merge all scopes - - const mdxContent = `export const name = "my-skill" // Step 6: Compile MDX with full scope (using system default tool references) -export const description = "A skill for {profile.username}" - -# Welcome, {profile.name}! - -Your role: {profile.role} -Search tool: {tool.websearch} -Fetch tool: {tool.webfetch} -Plugin version: {myPlugin.version} -Debug mode: {myPlugin.config.debug}` - - const result = await mdxToMd(mdxContent, {scope: mergedScope, extractMetadata: true}) - - expect(result.content).toContain('# Welcome, John Doe!') // Verify content compilation - expect(result.content).toContain('Your role: developer') - expect(result.content).toContain('Search tool: web_search') - expect(result.content).toContain('Fetch tool: web_fetch') - expect(result.content).toContain('Plugin version: 3.0.0') - expect(result.content).toContain('Debug mode: true') - - expect(result.metadata.fields['name']).toBe('my-skill') // Verify metadata extraction - expect(result.metadata.fields['description']).toBe('A skill for {profile.username}') - expect(result.metadata.source).toBe('export') - - expect(result.content).not.toContain('export const') // Verify exports are removed - }) - - it('should handle empty user config gracefully', async () => { - const collector = new GlobalScopeCollector() // No user config - const globalScope = collector.collect() - - const registry = new ScopeRegistry() - registry.setGlobalScope(globalScope) - - const mergedScope = registry.merge() - - const content = `Platform: {os.platform} -Default search: {tool.websearch}` - - const result = await mdxToMd(content, {scope: mergedScope}) - - expect(result).toContain('Platform:') // Should use system defaults - expect(result).toContain('Default search: web_search') - }) - - it('should preserve scope isolation between compilations', async () => { - const globalScope: MdxGlobalScope = { - os: {platform: 'linux', shellKind: ShellKind.Bash}, - env: {}, - profile: {name: 'User'}, - tool: {} - } - - const result1 = await mdxToMd('Hello {profile.name}', { // First compilation with custom scope - globalScope, - scope: {profile: {name: 'Custom User'}} - }) - - const result2 = await mdxToMd('Hello {profile.name}', { // Second compilation without custom scope - globalScope - }) - - expect(result1).toContain('Hello Custom User') // First should use custom scope - expect(result2).toContain('Hello User') // Second should use global scope (not affected by first) - }) - }) - - describe('edge cases', () => { - it('should handle undefined values in scope gracefully', async () => { - const globalScope: MdxGlobalScope = { - os: {platform: 'linux', shellKind: ShellKind.Bash}, - env: {}, - profile: {}, - tool: {} - } - - const content = `Platform: {os.platform}` // This should not throw, undefined values are handled - const result = await mdxToMd(content, {globalScope}) - - expect(result).toContain('Platform: linux') - }) - - it('should handle deeply nested scope values', async () => { - const scope = { - config: { - database: { - connection: { - host: 'localhost', - port: 5432 - } - } - } - } - - const content = `Host: {config.database.connection.host} -Port: {config.database.connection.port}` - - const result = await mdxToMd(content, {scope}) - - expect(result).toContain('Host: localhost') - expect(result).toContain('Port: 5432') - }) - - it('should handle array values in scope', async () => { - const scope = { - tags: ['typescript', 'testing', 'integration'], - config: { - features: ['feature1', 'feature2'] - } - } - - const content = `Tags: {tags}` // Arrays are converted to string representation - const result = await mdxToMd(content, {scope}) - - expect(result).toContain('Tags:') - }) - }) -}) diff --git a/cli/src/config.test.ts b/cli/src/config.test.ts deleted file mode 100644 index 7e3a5af1..00000000 --- a/cli/src/config.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {describe, it} from 'vitest' -import defineConfig from '@/plugin.config' - -describe('a', () => { - it('a', () => { - const r = defineConfig - console.log(r) - console.log('a') - }) -}) diff --git a/cli/src/config.ts b/cli/src/config.ts index 3b52f910..14af96a8 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -1,4 +1,4 @@ -import type {CollectedInputContext, ConfigLoaderOptions, FastCommandSeriesOptions, FastCommandSeriesPluginOverride, InputPlugin, InputPluginContext, OutputPlugin, PluginOptions, ShadowSourceProjectConfig, UserConfigFile} from '@truenine/plugin-shared' +import type {CollectedInputContext, CommandSeriesOptions, CommandSeriesPluginOverride, ConfigLoaderOptions, InputPlugin, InputPluginContext, OutputPlugin, PluginOptions, ShadowSourceProjectConfig, UserConfigFile} from '@truenine/plugin-shared' import * as fs from 'node:fs' import * as path from 'node:path' import process from 'node:process' @@ -20,7 +20,7 @@ export interface PipelineConfig { const DEFAULT_SHADOW_SOURCE_PROJECT: Required = { name: 'aindex', skill: {src: 'src/skills', dist: 'dist/skills'}, - fastCommand: {src: 'src/commands', dist: 'dist/commands'}, + command: {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'}, @@ -33,7 +33,7 @@ const DEFAULT_OPTIONS: Required = { workspaceDir: '~/project', logLevel: 'info', shadowSourceProject: DEFAULT_SHADOW_SOURCE_PROJECT, - fastCommandSeriesOptions: {}, + commandSeriesOptions: {}, plugins: [] } @@ -46,7 +46,7 @@ function userConfigToPluginOptions(userConfig: UserConfigFile): Partial ): Required { const overridePlugins = override.plugins - const overrideFastCommandSeries = override.fastCommandSeriesOptions + const overrideCommandSeries = override.commandSeriesOptions return { ...base, @@ -93,7 +93,7 @@ function mergeTwoConfigs( ...base.plugins, ...overridePlugins ?? [] ], - fastCommandSeriesOptions: mergeFastCommandSeriesOptions(base.fastCommandSeriesOptions, overrideFastCommandSeries) // Deep merge for fastCommandSeriesOptions + commandSeriesOptions: mergeCommandSeriesOptions(base.commandSeriesOptions, overrideCommandSeries) // Deep merge for commandSeriesOptions } } @@ -105,7 +105,7 @@ function mergeShadowSourceProject( return { name: override.name ?? base.name, skill: {...base.skill, ...override.skill}, - fastCommand: {...base.fastCommand, ...override.fastCommand}, + command: {...base.command, ...override.command}, subAgent: {...base.subAgent, ...override.subAgent}, rule: {...base.rule, ...override.rule}, globalMemory: {...base.globalMemory, ...override.globalMemory}, @@ -114,14 +114,14 @@ function mergeShadowSourceProject( } } -function mergeFastCommandSeriesOptions( - base?: FastCommandSeriesOptions, - override?: FastCommandSeriesOptions -): FastCommandSeriesOptions { +function mergeCommandSeriesOptions( + base?: CommandSeriesOptions, + override?: CommandSeriesOptions +): CommandSeriesOptions { if (override == null) return base ?? {} if (base == null) return override - const mergedPluginOverrides: Record = {} // Merge pluginOverrides deeply + const mergedPluginOverrides: Record = {} // Merge pluginOverrides deeply if (base.pluginOverrides != null) { // Copy base plugin overrides for (const [key, value] of Object.entries(base.pluginOverrides)) mergedPluginOverrides[key] = {...value} @@ -237,7 +237,7 @@ export async function defineConfig(options: PluginOptions | DefineConfigOptions ...merged.vscodeConfigFiles != null && {vscodeConfigFiles: merged.vscodeConfigFiles}, ...merged.jetbrainsConfigFiles != null && {jetbrainsConfigFiles: merged.jetbrainsConfigFiles}, ...merged.editorConfigFiles != null && {editorConfigFiles: merged.editorConfigFiles}, - ...merged.fastCommands != null && {fastCommands: merged.fastCommands}, + ...merged.commands != null && {commands: merged.commands}, ...merged.subAgents != null && {subAgents: merged.subAgents}, ...merged.skills != null && {skills: merged.skills}, ...merged.rules != null && {rules: merged.rules}, diff --git a/cli/src/config/ConfigService.test.ts b/cli/src/config/ConfigService.test.ts deleted file mode 100644 index 85050217..00000000 --- a/cli/src/config/ConfigService.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * 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 index 79f8c1b0..a9ff3269 100644 --- a/cli/src/config/ConfigService.ts +++ b/cli/src/config/ConfigService.ts @@ -121,23 +121,11 @@ export class ConfigService { } 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 + const config = this.load() + return { + config, + source: this.configPath, + found: true } } @@ -173,32 +161,6 @@ export class ConfigService { 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: '' - } - } - } } /** diff --git a/cli/src/config/index.ts b/cli/src/config/index.ts index 63a8efc6..1f6a8e8e 100644 --- a/cli/src/config/index.ts +++ b/cli/src/config/index.ts @@ -64,7 +64,6 @@ export { // Export path resolution utilities export { // Export schema and validation formatValidationErrors, - getDefaultConfig, isValidLogLevel, safeValidateConfig, validateConfig, diff --git a/cli/src/config/pathResolver.test.ts b/cli/src/config/pathResolver.test.ts deleted file mode 100644 index 17803722..00000000 --- a/cli/src/config/pathResolver.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * 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/schema.test.ts b/cli/src/config/schema.test.ts deleted file mode 100644 index e9899a05..00000000 --- a/cli/src/config/schema.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * 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 index 985cfb89..29d0da1c 100644 --- a/cli/src/config/schema.ts +++ b/cli/src/config/schema.ts @@ -33,9 +33,9 @@ export const ZModulePaths = z.object({ /** * Zod schema for aindex configuration. + * Supports user-defined module paths with src/dist structure. */ export const ZAindexConfig = z.object({ - name: z.string().min(1, 'Aindex name cannot be empty'), skills: ZModulePaths, commands: ZModulePaths, subAgents: ZModulePaths, @@ -45,7 +45,7 @@ export const ZAindexConfig = z.object({ app: ZModulePaths, ext: ZModulePaths, arch: ZModulePaths -}) satisfies z.ZodType +}).catchall(ZModulePaths) satisfies z.ZodType /** * Zod schema for user profile. @@ -118,37 +118,6 @@ 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, diff --git a/cli/src/config/types.ts b/cli/src/config/types.ts index fdafd828..5996d79d 100644 --- a/cli/src/config/types.ts +++ b/cli/src/config/types.ts @@ -19,10 +19,9 @@ export interface ModulePaths { /** * Aindex configuration containing all module paths. * This replaces the previous shadowSourceProject configuration. + * Supports user-defined module paths with src/dist structure. */ export interface AindexConfig { - /** Name of the aindex configuration */ - readonly name: string /** Skills module paths */ readonly skills: ModulePaths /** Commands module paths */ @@ -41,6 +40,8 @@ export interface AindexConfig { readonly ext: ModulePaths /** Architecture module paths */ readonly arch: ModulePaths + /** User-defined module paths - allows any additional module configuration */ + readonly [key: string]: ModulePaths } /** diff --git a/cli/src/inputs/effect-orphan-cleanup.ts b/cli/src/inputs/effect-orphan-cleanup.ts index b9a9566c..9d71f0ad 100644 --- a/cli/src/inputs/effect-orphan-cleanup.ts +++ b/cli/src/inputs/effect-orphan-cleanup.ts @@ -34,7 +34,7 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { const shadowConfig = userConfigOptions.shadowSourceProject const srcPaths: Record = { skills: shadowConfig?.skill?.src ?? 'src/skills', - commands: shadowConfig?.fastCommand?.src ?? 'src/commands', + commands: shadowConfig?.command?.src ?? 'src/commands', agents: shadowConfig?.subAgent?.src ?? 'src/agents', app: shadowConfig?.project?.src ?? 'app' } diff --git a/cli/src/inputs/index.ts b/cli/src/inputs/index.ts index 7468df48..a9e222b3 100644 --- a/cli/src/inputs/index.ts +++ b/cli/src/inputs/index.ts @@ -11,12 +11,12 @@ export { export { SkillInputPlugin } from './input-agentskills' +export { + CommandInputPlugin +} from './input-command' export { EditorConfigInputPlugin } from './input-editorconfig' -export { - FastCommandInputPlugin -} from './input-fast-command' export { GitExcludeInputPlugin } from './input-git-exclude' diff --git a/cli/src/inputs/input-fast-command.ts b/cli/src/inputs/input-command.ts similarity index 76% rename from cli/src/inputs/input-fast-command.ts rename to cli/src/inputs/input-command.ts index 5dee1333..5b8b53d0 100644 --- a/cli/src/inputs/input-fast-command.ts +++ b/cli/src/inputs/input-command.ts @@ -1,9 +1,9 @@ import type { CollectedInputContext, - FastCommandPrompt, + CommandPrompt, InputPluginContext, Locale, - LocalizedFastCommandPrompt, + LocalizedCommandPrompt, PluginOptions, ResolvedBasePaths } from '@truenine/plugin-shared' @@ -21,16 +21,16 @@ export interface SeriesInfo { readonly commandName: string } -export class FastCommandInputPlugin extends AbstractInputPlugin { +export class CommandInputPlugin extends AbstractInputPlugin { constructor() { - super('FastCommandInputPlugin') + super('CommandInputPlugin') } private getDistDir(options: Required, resolvedPaths: ResolvedBasePaths): string { - return this.resolveShadowPath(options.shadowSourceProject.fastCommand.dist, resolvedPaths.shadowProjectDir) + return this.resolveShadowPath(options.shadowSourceProject.command.dist, resolvedPaths.shadowProjectDir) } - private createFastCommandPrompt( + private createCommandPrompt( content: string, _locale: Locale, name: string, @@ -38,7 +38,7 @@ export class FastCommandInputPlugin extends AbstractInputPlugin { distDir: string, ctx: InputPluginContext, _rawContent?: string - ): FastCommandPrompt { + ): CommandPrompt { const {path} = ctx const slashIndex = name.indexOf('/') @@ -51,7 +51,7 @@ export class FastCommandInputPlugin extends AbstractInputPlugin { const entryName = `${name}.mdx` return { - type: PromptKind.FastCommand, + type: PromptKind.Command, content, length: content.length, filePathKind: FilePathKind.Relative, @@ -64,7 +64,7 @@ export class FastCommandInputPlugin extends AbstractInputPlugin { }, ...seriesInfo.series != null && {series: seriesInfo.series}, commandName: seriesInfo.commandName - } as FastCommandPrompt + } as CommandPrompt } extractSeriesInfo(fileName: string, parentDirName?: string): SeriesInfo { @@ -91,19 +91,25 @@ export class FastCommandInputPlugin extends AbstractInputPlugin { const {userConfigOptions: options, logger, path, fs, globalScope} = ctx const resolvedPaths = this.resolveBasePaths(options) - const srcDir = this.resolveShadowPath(options.shadowSourceProject.fastCommand.src, resolvedPaths.shadowProjectDir) + const srcDir = this.resolveShadowPath(options.shadowSourceProject.command.src, resolvedPaths.shadowProjectDir) const distDir = this.getDistDir(options, resolvedPaths) + logger.debug('CommandInputPlugin collecting', { + srcDir, + distDir, + shadowProjectDir: resolvedPaths.shadowProjectDir + }) + const reader = createLocalizedPromptReader(fs, path, logger, globalScope) const {prompts: localizedCommands, errors} = await reader.readFlatFiles( srcDir, distDir, { - kind: PromptKind.FastCommand, + kind: PromptKind.Command, localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, isDirectoryStructure: false, - createPrompt: async (content, locale, name) => this.createFastCommandPrompt( + createPrompt: async (content, locale, name) => this.createCommandPrompt( content, locale, name, @@ -114,15 +120,25 @@ export class FastCommandInputPlugin extends AbstractInputPlugin { } ) + logger.debug('CommandInputPlugin read complete', { + commandCount: localizedCommands.length, + errorCount: errors.length + }) + for (const error of errors) logger.warn('Failed to read command', {path: error.path, phase: error.phase, error: error.error}) - const legacyCommands: FastCommandPrompt[] = [] + const legacyCommands: CommandPrompt[] = [] for (const localized of localizedCommands) { const prompt = localized.dist?.prompt ?? localized.src.default.prompt if (prompt) legacyCommands.push(prompt) } - const promptIndex = new Map() + logger.debug('CommandInputPlugin legacy commands', { + count: legacyCommands.length, + commands: legacyCommands.map(c => c.commandName) + }) + + const promptIndex = new Map() for (const cmd of localizedCommands) promptIndex.set(cmd.name, cmd) return { @@ -134,7 +150,7 @@ export class FastCommandInputPlugin extends AbstractInputPlugin { readme: [] }, promptIndex, - fastCommands: legacyCommands + commands: legacyCommands } } } diff --git a/cli/src/inputs/input-subagent.ts b/cli/src/inputs/input-subagent.ts index 617ee26e..8fd1ccd6 100644 --- a/cli/src/inputs/input-subagent.ts +++ b/cli/src/inputs/input-subagent.ts @@ -93,6 +93,12 @@ export class SubAgentInputPlugin extends AbstractInputPlugin { const srcDir = this.resolveShadowPath(options.shadowSourceProject.subAgent.src, resolvedPaths.shadowProjectDir) const distDir = this.getDistDir(options, resolvedPaths) + logger.debug('SubAgentInputPlugin collecting', { + srcDir, + distDir, + shadowProjectDir: resolvedPaths.shadowProjectDir + }) + const reader = createLocalizedPromptReader(fs, path, logger, globalScope) const {prompts: localizedSubAgents, errors} = await reader.readFlatFiles( @@ -100,7 +106,7 @@ export class SubAgentInputPlugin extends AbstractInputPlugin { distDir, { kind: PromptKind.SubAgent, - localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, + localeExtensions: {zh: '.md', en: '.mdx'}, isDirectoryStructure: false, createPrompt: async (content, locale, name) => this.createSubAgentPrompt( content, @@ -113,6 +119,11 @@ export class SubAgentInputPlugin extends AbstractInputPlugin { } ) + logger.debug('SubAgentInputPlugin read complete', { + subAgentCount: localizedSubAgents.length, + errorCount: errors.length + }) + for (const error of errors) logger.warn('Failed to read subAgent', {path: error.path, phase: error.phase, error: error.error}) const legacySubAgents: SubAgentPrompt[] = [] @@ -121,6 +132,11 @@ export class SubAgentInputPlugin extends AbstractInputPlugin { if (prompt) legacySubAgents.push(prompt) } + logger.debug('SubAgentInputPlugin legacy subAgents', { + count: legacySubAgents.length, + agents: legacySubAgents.map(a => a.agentName) + }) + const promptIndex = new Map() for (const sub of localizedSubAgents) promptIndex.set(sub.name, sub) diff --git a/cli/src/pipeline/ContextMerger.ts b/cli/src/pipeline/ContextMerger.ts index ea2b7a19..05221abc 100644 --- a/cli/src/pipeline/ContextMerger.ts +++ b/cli/src/pipeline/ContextMerger.ts @@ -38,9 +38,9 @@ const FIELD_CONFIGS: Record> = { strategy: 'concat', getter: ctx => ctx.editorConfigFiles }, - fastCommands: { + commands: { strategy: 'concat', - getter: ctx => ctx.fastCommands + getter: ctx => ctx.commands }, subAgents: { strategy: 'concat', diff --git a/cli/src/plugin.config.ts b/cli/src/plugin.config.ts index cd0de8b9..ac0c61df 100644 --- a/cli/src/plugin.config.ts +++ b/cli/src/plugin.config.ts @@ -1,6 +1,5 @@ import {GenericSkillsOutputPlugin} from '@truenine/plugin-agentskills-compact' import {AgentsOutputPlugin} from '@truenine/plugin-agentsmd' -import {AntigravityOutputPlugin} from '@truenine/plugin-antigravity' import {ClaudeCodeCLIOutputPlugin} from '@truenine/plugin-claude-code-cli' import {CursorOutputPlugin} from '@truenine/plugin-cursor' import {DroidCLIOutputPlugin} from '@truenine/plugin-droid-cli' @@ -20,8 +19,8 @@ import {WindsurfOutputPlugin} from '@truenine/plugin-windsurf' import {defineConfig} from '@/config' import { AIAgentIgnoreInputPlugin, + CommandInputPlugin, EditorConfigInputPlugin, - FastCommandInputPlugin, GitExcludeInputPlugin, GitIgnoreInputPlugin, GlobalMemoryInputPlugin, @@ -43,7 +42,6 @@ import {TraeCNIDEOutputPlugin} from '@/plugins/plugin-trae-cn-ide' export default defineConfig({ plugins: [ new AgentsOutputPlugin(), - new AntigravityOutputPlugin(), new ClaudeCodeCLIOutputPlugin(), new CodexCLIOutputPlugin(), new JetBrainsAIAssistantCodexOutputPlugin(), @@ -74,7 +72,7 @@ export default defineConfig({ new JetBrainsConfigInputPlugin(), new EditorConfigInputPlugin(), new SkillInputPlugin(), - new FastCommandInputPlugin(), + new CommandInputPlugin(), new SubAgentInputPlugin(), new RuleInputPlugin(), new GlobalMemoryInputPlugin(), diff --git a/cli/src/plugins/desk-paths/index.property.test.ts b/cli/src/plugins/desk-paths/index.property.test.ts deleted file mode 100644 index da935029..00000000 --- a/cli/src/plugins/desk-paths/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/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts deleted file mode 100644 index 44115776..00000000 --- a/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts +++ /dev/null @@ -1,450 +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 both .agents/skills and legacy .skills directories 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(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 () => { - 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 return empty array (no global output dirs)', 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(0) - }) - }) - - 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.registerGlobalOutputFiles(ctx) - - expect(results).toHaveLength(0) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should register skill files 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) // 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 () => { - 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.registerProjectOutputFiles(ctx) - - 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', 'mcp.json')) - }) - - it('should register child docs', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [createMockSkillPrompt('test-skill', 'content', { - childDocs: [{relativePath: 'doc1.mdx', content: 'doc content'}] - })] - }) - - const results = await plugin.registerProjectOutputFiles(ctx) - - 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 register resources', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}] - }, - skills: [createMockSkillPrompt('test-skill', 'content', { - resources: [{relativePath: 'resource.json', content: '{}', encoding: 'text'}] - })] - }) - - const results = await plugin.registerProjectOutputFiles(ctx) - - 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 write skill files directly to project directory', async () => { - 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) // 2 projects * 1 skill = 2 files - expect(results.files[0]?.success).toBe(true) - expect(results.files[1]?.success).toBe(true) - - 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', 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(vi.mocked(fs.writeFileSync)).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) - }) - - 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) - }) - - 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('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('test-skill', 'content')] - }) - - const results = await plugin.writeGlobalOutputs(ctx) - - expect(results.files).toHaveLength(0) - expect(results.dirs).toHaveLength(0) - expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() - }) - }) -}) diff --git a/cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.test.ts b/cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.test.ts deleted file mode 100644 index 6884c8c9..00000000 --- a/cli/src/plugins/plugin-antigravity/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/cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.ts b/cli/src/plugins/plugin-antigravity/AntigravityOutputPlugin.ts deleted file mode 100644 index 1f274575..00000000 --- a/cli/src/plugins/plugin-antigravity/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/cli/src/plugins/plugin-antigravity/index.ts b/cli/src/plugins/plugin-antigravity/index.ts deleted file mode 100644 index 784b1336..00000000 --- a/cli/src/plugins/plugin-antigravity/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 442b8f95..00000000 --- a/cli/src/plugins/plugin-claude-code-cli/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' -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 deleted file mode 100644 index f1f2536c..00000000 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts +++ /dev/null @@ -1,156 +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).toMatch(new RegExp(`-\\s+['"]?${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?`)) // Accept quoted or unquoted formats - }), {numRuns: 100}) - }) - }) - - describe('write output format verification', () => { - it('should NOT write global rule files (all rules go to project level)', 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(false) - }), {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).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 deleted file mode 100644 index 064db7d3..00000000 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts +++ /dev/null @@ -1,520 +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 return empty array since all outputs go to project level', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - expect(dirs).toHaveLength(0) - }) - }) - - 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) - - 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 not register fast commands globally (only project level)', 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).toBeUndefined() - }) - - it('should not register sub agents globally (only project level)', 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).toBeUndefined() - }) - - it('should not register skills globally (only project level)', 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).toBeUndefined() - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should register project CLAUDE.md and project-level commands/agents/skills', 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 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 mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('code-review.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'desc'} - } - - 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 ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - }, - fastCommands: [mockCmd], - subAgents: [mockAgent], - skills: [mockSkill] - } - } - - const files = await plugin.registerProjectOutputFiles(ctxWithProject) - - const claudeFile = files.find(f => f.path.includes('CLAUDE.md')) // Check CLAUDE.md - expect(claudeFile).toBeDefined() - - const cmdFile = files.find(f => f.path.includes('test-cmd.md')) // Check command - expect(cmdFile).toBeDefined() - expect(cmdFile?.path).toContain('commands') - - const agentFile = files.find(f => f.path.includes('code-review.cn.md')) // Check agent (should have .md not .mdx) - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('agents') - expect(agentFile?.path).not.toContain('.mdx') - - const skillFile = files.find(f => f.path.includes('SKILL.md')) // Check skill - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain('skills') - }) - }) - - describe('writeGlobalOutputs', () => { - it('should not write sub agents globally (only project level)', 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).toBeUndefined() - - const writtenPath = path.join(tempDir, '.claude', 'agents', 'reviewer.cn.md') // Verify file was not written globally - expect(fs.existsSync(writtenPath)).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).toMatch(/-\s+['"]?\*\*\/\*\.ts['"]?/) // Accept quoted or unquoted formats - expect(content).toMatch(/-\s+['"]?\*\*\/\*\.tsx['"]?/) - }) - - 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).toMatch(/-\s+['"]?src\/components\/\*\.tsx['"]?/) // Accept quoted or unquoted formats - expect(content).toMatch(/-\s+['"]?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 not register rules globally (only project level)', 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)).not.toContain('rules') - }) - - it('should register rules at project level', 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: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})] - } - } - const dirs = await plugin.registerProjectOutputDirs(ctx) - expect(dirs.map(d => d.path)).toContain(path.join('proj', '.claude', 'rules')) - }) - - it('should not register global rule files (only project level)', 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).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 not write global rule files (only project level)', 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).toBeUndefined() - - const filePath = path.join(tempDir, '.claude', 'rules', 'rule-01-ts.md') - expect(fs.existsSync(filePath)).toBe(false) - }) - }) - - describe('writeProjectOutputs with rules', () => { - it('should write all rules to {project}/.claude/rules/ (including previously global scoped)', 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: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global', content: '# TS rule'}), - 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-01-ts.md' && f.success)).toBe(true) - expect(results.files.some(f => f.path.path === 'rule-02-api.md' && f.success)).toBe(true) - - const filePath1 = path.join(tempDir, 'proj', '.claude', 'rules', 'rule-01-ts.md') - const filePath2 = path.join(tempDir, 'proj', '.claude', 'rules', 'rule-02-api.md') - expect(fs.existsSync(filePath1)).toBe(true) - expect(fs.existsSync(filePath2)).toBe(true) - }) - - it('should write rule without frontmatter when globs is empty', 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: '01', ruleName: 'general', globs: [], scope: 'global', content: '# Always apply'})] - } - } - await plugin.writeProjectOutputs(ctx as any) - const filePath = path.join(tempDir, 'proj', '.claude', 'rules', 'rule-01-general.md') - const content = fs.readFileSync(filePath, 'utf8') - expect(content).toBe('# Always apply') - expect(content).not.toContain('---') - }) - }) -}) diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts index 7d6b4d99..96b830eb 100644 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts @@ -7,6 +7,9 @@ import {applySubSeriesGlobPrefix, BaseCLIOutputPlugin, filterRulesByProjectConfi const PROJECT_MEMORY_FILE = 'CLAUDE.md' const GLOBAL_CONFIG_DIR = '.claude' const RULES_SUBDIR = 'rules' +const COMMANDS_SUBDIR = 'commands' +const AGENTS_SUBDIR = 'agents' +const SKILLS_SUBDIR = 'skills' const RULE_FILE_PREFIX = 'rule-' /** @@ -24,9 +27,12 @@ export class ClaudeCodeCLIOutputPlugin extends BaseCLIOutputPlugin { globalConfigDir: GLOBAL_CONFIG_DIR, outputFileName: PROJECT_MEMORY_FILE, toolPreset: 'claudeCode', - supportsFastCommands: true, + supportsCommands: true, supportsSubAgents: true, - supportsSkills: true + supportsSkills: true, + commandsSubDir: COMMANDS_SUBDIR, + agentsSubDir: AGENTS_SUBDIR, + skillsSubDir: SKILLS_SUBDIR }) } diff --git a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.projectConfig.test.ts deleted file mode 100644 index 0a7209c3..00000000 --- a/cli/src/plugins/plugin-cursor/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' -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 deleted file mode 100644 index e0ea8445..00000000 --- a/cli/src/plugins/plugin-cursor/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/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts index ed2811a8..b7b459a0 100644 --- a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts +++ b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts @@ -1,5 +1,5 @@ import type { - FastCommandPrompt, + CommandPrompt, OutputPluginContext, OutputWriteContext, RulePrompt, @@ -75,11 +75,11 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { const results: RelativePath[] = [] const globalDir = this.getGlobalConfigDir() - const {fastCommands, skills, rules} = ctx.collectedInputContext + const {commands, skills, rules} = ctx.collectedInputContext const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + if (commands != null && commands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(commands, projectConfig) if (filteredCommands.length > 0) { const commandsDir = this.getGlobalCommandsDir() results.push({pathKind: FilePathKind.Relative, path: COMMANDS_SUBDIR, basePath: globalDir, getDirectoryName: () => COMMANDS_SUBDIR, getAbsolutePath: () => commandsDir}) @@ -107,7 +107,7 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { const results: RelativePath[] = [] const globalDir = this.getGlobalConfigDir() - const {skills, fastCommands} = ctx.collectedInputContext + const {skills, commands} = ctx.collectedInputContext const projectConfig = this.resolvePromptSourceProjectConfig(ctx) const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] const hasAnyMcpConfig = filteredSkills.some(s => s.mcpConfig != null) @@ -117,12 +117,12 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { 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) + if (commands != null && commands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(commands, projectConfig) const commandsDir = this.getGlobalCommandsDir() const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) for (const cmd of filteredCommands) { - const fileName = this.transformFastCommandName(cmd, transformOptions) + const fileName = this.transformCommandName(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}) } @@ -203,9 +203,9 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, skills, fastCommands, globalMemory, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + const {workspace, skills, commands, globalMemory, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasSkills = (skills?.length ?? 0) > 0 - const hasFastCommands = (fastCommands?.length ?? 0) > 0 + const hasFastCommands = (commands?.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 @@ -215,7 +215,7 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { } async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {skills, fastCommands, rules} = ctx.collectedInputContext + const {skills, commands, rules} = ctx.collectedInputContext const projectConfig = this.resolvePromptSourceProjectConfig(ctx) const fileResults: WriteResult[] = [] const dirResults: WriteResult[] = [] @@ -232,10 +232,10 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { } } - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + if (commands != null && commands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(commands, projectConfig) const commandsDir = this.getGlobalCommandsDir() - for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalFastCommand(ctx, commandsDir, cmd)) + for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalCommand(ctx, commandsDir, cmd)) } const globalRules = rules?.filter(r => this.normalizeRuleScope(r) === 'global') @@ -303,9 +303,9 @@ export class CursorOutputPlugin extends AbstractOutputPlugin { 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 { + private async writeGlobalCommand(ctx: OutputWriteContext, commandsDir: string, cmd: CommandPrompt): Promise { const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) - const fileName = this.transformFastCommandName(cmd, transformOptions) + const fileName = this.transformCommandName(cmd, transformOptions) const fullPath = path.join(commandsDir, fileName) const globalDir = this.getGlobalConfigDir() const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(COMMANDS_SUBDIR, fileName), basePath: globalDir, getDirectoryName: () => COMMANDS_SUBDIR, getAbsolutePath: () => fullPath} diff --git a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts deleted file mode 100644 index 8500e9cd..00000000 --- a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts +++ /dev/null @@ -1,354 +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 return empty array since all outputs go to project level', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - expect(dirs).toHaveLength(0) - }) - }) - - 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 NOT register fast commands globally (only project level)', 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).toBeUndefined() - }) - - it('should NOT register sub agents globally (only project level)', 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).toBeUndefined() - }) - - it('should NOT register sub agents globally (mdx test)', 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).toBeUndefined() - }) - - it('should NOT register skills globally (only project level)', 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).toBeUndefined() - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should register project-level commands, agents, and skills', 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 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 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 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 ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - }, - fastCommands: [mockCmd], - subAgents: [mockAgent], - skills: [mockSkill] - } - } - - const files = await plugin.registerProjectOutputFiles(ctxWithProject) - - const cmdFile = files.find(f => f.path.includes('test-cmd.md')) // Check command - expect(cmdFile).toBeDefined() - expect(cmdFile?.path).toContain('commands') - - const agentFile = files.find(f => f.path.includes('test-agent.md')) // Check agent - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('agents') - - const skillFile = files.find(f => f.path.includes('SKILL.md')) // Check skill - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain('skills') - }) - - it('should strip .mdx suffix from sub agent path at project level', 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 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 ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - }, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerProjectOutputFiles(ctxWithProject) - 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') - }) - }) -}) diff --git a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts index 3d4333db..546d3673 100644 --- a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts @@ -14,7 +14,7 @@ export class DroidCLIOutputPlugin extends BaseCLIOutputPlugin { super('DroidCLIOutputPlugin', { globalConfigDir: GLOBAL_CONFIG_DIR, outputFileName: GLOBAL_MEMORY_FILE, - supportsFastCommands: true, + supportsCommands: true, supportsSubAgents: true, supportsSkills: true }) // Droid uses default subdir names diff --git a/cli/src/plugins/plugin-gemini-cli/GeminiCLIOutputPlugin.ts b/cli/src/plugins/plugin-gemini-cli/GeminiCLIOutputPlugin.ts index 6f6828b4..7d2a6887 100644 --- a/cli/src/plugins/plugin-gemini-cli/GeminiCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-gemini-cli/GeminiCLIOutputPlugin.ts @@ -8,7 +8,7 @@ export class GeminiCLIOutputPlugin extends BaseCLIOutputPlugin { super('GeminiCLIOutputPlugin', { globalConfigDir: GLOBAL_CONFIG_DIR, outputFileName: PROJECT_MEMORY_FILE, - supportsFastCommands: false, + supportsCommands: false, supportsSubAgents: false, supportsSkills: false }) diff --git a/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.test.ts b/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.test.ts deleted file mode 100644 index 4c0b1de6..00000000 --- a/cli/src/plugins/plugin-git-exclude/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/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts b/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.test.ts deleted file mode 100644 index c9708abd..00000000 --- a/cli/src/plugins/plugin-input-shared/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: 'aindex', - 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/aindex')) - }) - - 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/LocalizedPromptReader.ts b/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts index 2667e3b4..4d8e8f72 100644 --- a/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts +++ b/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts @@ -16,6 +16,10 @@ import {parseMarkdown} from '@truenine/md-compiler/markdown' // Re-export types * Universal reader for localized prompts * Handles reading src (multiple locales) and dist (compiled) content * Supports directory structures (skills) and flat files (commands, subAgents) + * + * Note: src and dist are treated as coexisting sources, not fallbacks. + * Both are read independently. If dist exists, it's included in the result. + * If src exists, it's compiled and included. Neither replaces the other. */ export class LocalizedPromptReader { constructor( @@ -90,13 +94,18 @@ export class LocalizedPromptReader { const prompts: LocalizedPrompt[] = [] const errors: ReadError[] = [] - if (!this.exists(srcDir)) return {prompts, errors} + const srcExists = this.exists(srcDir) + const distExists = this.exists(distDir) + + this.logger.debug(`readFlatFiles: srcDir=${srcDir}, exists=${srcExists}`) + this.logger.debug(`readFlatFiles: distDir=${distDir}, exists=${distExists}`) + + if (!srcExists) return {prompts, errors} const zhExtension = options.localeExtensions.zh // Find all .cn.mdx files (Chinese source files) try { const entries = this.fs.readdirSync(srcDir, {withFileTypes: true}) - for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(zhExtension)) continue @@ -159,71 +168,58 @@ export class LocalizedPromptReader { ): Promise | null> { const {localeExtensions, entryFileName, createPrompt, kind} = options - const baseFileName = entryFileName ?? name // For flat: read src/{name}.cn.mdx and src/{name}.mdx // For skills: read src/{name}/skill.cn.mdx and src/{name}/skill.mdx + const baseFileName = entryFileName ?? name const srcZhPath = this.path.join(srcEntryDir, `${baseFileName}${localeExtensions.zh}`) const srcEnPath = this.path.join(srcEntryDir, `${baseFileName}${localeExtensions.en}`) const distPath = this.path.join(distEntryDir, `${baseFileName}.mdx`) - const distContent = await this.readDistContent(distPath, createPrompt, name) // Priority 1: Try dist first (already compiled, no need to recompile) - if (distContent) { - let children: string[] | undefined // Dist exists, use it directly (skip src entirely) - if (isDirectoryStructure) children = this.scanChildren(distEntryDir, baseFileName, localeExtensions.zh) + const distContent = await this.readDistContent(distPath, createPrompt, name) // Read both src and dist independently - no fallback logic + const zhContent = await this.readLocaleContent(srcZhPath, 'zh', createPrompt, name) + const enContent = await this.readLocaleContent(srcEnPath, 'en', createPrompt, name) - return { - name, - type: kind, - src: { - zh: distContent, - default: distContent, - defaultLocale: 'zh' - }, - dist: distContent, - metadata: { - hasDist: true, - hasMultipleLocales: false, - isDirectoryStructure, - ...children && children.length > 0 && {children} - }, - paths: { - zh: srcZhPath, - dist: distPath - } - } - } + const hasDist = distContent != null + const hasSrcZh = zhContent != null + const hasSrcEn = enContent != null - const zhContent = await this.readLocaleContent(srcZhPath, 'zh', createPrompt, name) // Read Chinese source (required) // Priority 2: Dist not exists, fall back to src - if (!zhContent) { - this.logger.warn(`Missing required Chinese source: ${srcZhPath}`) + if (!hasDist && !hasSrcZh) { // If neither src nor dist exists, return null + this.logger.warn(`Missing both dist and Chinese source for: ${name}`) return null } - const enContent = await this.readLocaleContent(srcEnPath, 'en', createPrompt, name) // Read English source (optional) + const src: LocalizedPrompt['src'] = hasSrcZh // Build src content object + ? { + zh: zhContent, + ...hasSrcEn && {en: enContent}, + default: zhContent, + defaultLocale: 'zh' + } + : { + zh: distContent!, + default: distContent!, + defaultLocale: 'zh' + } - const src: LocalizedPrompt['src'] = { - zh: zhContent, - ...enContent && {en: enContent}, - default: zhContent, - defaultLocale: 'zh' + let children: string[] | undefined + if (isDirectoryStructure) { + const scanDir = hasDist ? distEntryDir : srcEntryDir // Scan children from dist if available, otherwise from src + children = this.scanChildren(scanDir, baseFileName, localeExtensions.zh) } - const hasMultipleLocales = !!enContent - - let children: string[] | undefined // Determine children (for directory structures) - if (isDirectoryStructure) children = this.scanChildren(srcEntryDir, baseFileName, localeExtensions.zh) - return { name, type: kind, src, + ...hasDist && {dist: distContent}, metadata: { - hasDist: false, - hasMultipleLocales, + hasDist, + hasMultipleLocales: hasSrcEn, isDirectoryStructure, ...children && children.length > 0 && {children} }, paths: { - zh: srcZhPath, - ...this.exists(srcEnPath) && {en: srcEnPath} + ...(hasSrcZh || !hasDist) && {zh: srcZhPath}, + ...hasSrcEn && {en: srcEnPath}, + ...hasDist && {dist: distPath} } } } @@ -245,55 +241,49 @@ export class LocalizedPromptReader { const srcEnPath = `${baseName}${localeExtensions.en}` const distPath = this.path.join(distDir, `${name}.mdx`) - const distContent = await this.readDistContent(distPath, createPrompt, name) // Priority 1: Try dist first (already compiled, no need to recompile) - if (distContent) { - return { - name, - type: kind, - src: { - zh: distContent, - default: distContent, - defaultLocale: 'zh' - }, - dist: distContent, - metadata: { - hasDist: true, - hasMultipleLocales: false, - isDirectoryStructure: false - }, - paths: { - dist: distPath - } - } - } - - const fullSrcZhPath = isSingleFile ? srcZhPath : this.path.join(srcDir, srcZhPath) // Priority 2: Dist not exists, fall back to src + const fullSrcZhPath = isSingleFile ? srcZhPath : this.path.join(srcDir, srcZhPath) const fullSrcEnPath = isSingleFile ? srcEnPath : this.path.join(srcDir, srcEnPath) - const zhContent = await this.readLocaleContent(fullSrcZhPath, 'zh', createPrompt, name) // Read Chinese source (required) - if (!zhContent) return null + const distContent = await this.readDistContent(distPath, createPrompt, name) // Read both src and dist independently - no fallback logic + const zhContent = await this.readLocaleContent(fullSrcZhPath, 'zh', createPrompt, name) + const enContent = await this.readLocaleContent(fullSrcEnPath, 'en', createPrompt, name) - const enContent = await this.readLocaleContent(fullSrcEnPath, 'en', createPrompt, name) // Read English source (optional) + const hasDist = distContent != null + const hasSrcZh = zhContent != null + const hasSrcEn = enContent != null - const src: LocalizedPrompt['src'] = { - zh: zhContent, - ...enContent && {en: enContent}, - default: zhContent, - defaultLocale: 'zh' + if (!hasDist && !hasSrcZh) { // If neither src nor dist exists, return null + this.logger.warn(`Missing both dist and Chinese source for: ${name}`) + return null } + const src: LocalizedPrompt['src'] = hasSrcZh // Build src content object + ? { + zh: zhContent, + ...hasSrcEn && {en: enContent}, + default: zhContent, + defaultLocale: 'zh' + } + : { + zh: distContent!, + default: distContent!, + defaultLocale: 'zh' + } + return { name, type: kind, src, + ...hasDist && {dist: distContent}, metadata: { - hasDist: false, - hasMultipleLocales: !!enContent, + hasDist, + hasMultipleLocales: hasSrcEn, isDirectoryStructure: false }, paths: { - zh: fullSrcZhPath, - ...this.exists(fullSrcEnPath) ? {en: fullSrcEnPath} : {} + ...(hasSrcZh || !hasDist) && {zh: fullSrcZhPath}, + ...hasSrcEn && {en: fullSrcEnPath}, + ...hasDist && {dist: distPath} } } } diff --git a/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.test.ts b/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.test.ts deleted file mode 100644 index bb7fff4a..00000000 --- a/cli/src/plugins/plugin-jetbrains-ai-codex/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/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts b/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts index 3d66aede..e7e9a497 100644 --- a/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts +++ b/cli/src/plugins/plugin-jetbrains-ai-codex/JetBrainsAIAssistantCodexOutputPlugin.ts @@ -1,5 +1,5 @@ import type { - FastCommandPrompt, + CommandPrompt, OutputPluginContext, OutputWriteContext, Project, @@ -164,9 +164,9 @@ export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin } async canWrite(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands, skills, workspace, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + const {globalMemory, commands, skills, workspace, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasGlobalMemory = globalMemory != null - const hasFastCommands = (fastCommands?.length ?? 0) > 0 + const hasFastCommands = (commands?.length ?? 0) > 0 const hasSkills = (skills?.length ?? 0) > 0 const hasProjectPrompts = workspace.projects.some( project => project.rootMemoryPrompt != null || (project.childMemoryPrompts?.length ?? 0) > 0 @@ -211,7 +211,7 @@ export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin } async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands, skills} = ctx.collectedInputContext + const {globalMemory, commands, skills} = ctx.collectedInputContext const projectConfig = this.resolvePromptSourceProjectConfig(ctx) const fileResults: WriteResult[] = [] const dirResults: WriteResult[] = [] @@ -219,7 +219,7 @@ export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin if (codexDirs.length === 0) return {files: fileResults, dirs: dirResults} - const filteredCommands = fastCommands != null ? filterCommandsByProjectConfig(fastCommands, projectConfig) : [] + const filteredCommands = commands != null ? filterCommandsByProjectConfig(commands, projectConfig) : [] const filteredSkills = skills != null ? filterSkillsByProjectConfig(skills, projectConfig) : [] for (const codexDir of codexDirs) { @@ -253,7 +253,7 @@ export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin if (filteredCommands.length > 0) { for (const cmd of filteredCommands) { - const cmdResults = await this.writeGlobalFastCommand(ctx, codexDir, cmd) + const cmdResults = await this.writeGlobalCommand(ctx, codexDir, cmd) fileResults.push(...cmdResults) } } @@ -384,14 +384,14 @@ export class JetBrainsAIAssistantCodexOutputPlugin extends AbstractOutputPlugin return IDE_DIR_PREFIXES.some(prefix => dirName.startsWith(prefix)) } - private async writeGlobalFastCommand( + private async writeGlobalCommand( ctx: OutputWriteContext, codexDir: string, - cmd: FastCommandPrompt + cmd: CommandPrompt ): Promise { const results: WriteResult[] = [] const transformOptions = this.getTransformOptionsFromContext(ctx) - const fileName = this.transformFastCommandName(cmd, transformOptions) + const fileName = this.transformCommandName(cmd, transformOptions) const targetDir = path.join(codexDir, PROMPTS_SUBDIR) const fullPath = path.join(targetDir, fileName) diff --git a/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts b/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts index e9893253..34101bed 100644 --- a/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts @@ -1,5 +1,5 @@ import type { - FastCommandPrompt, + CommandPrompt, OutputPluginContext, OutputWriteContext, SkillPrompt, @@ -65,8 +65,8 @@ export class CodexCLIOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands, skills} = ctx.collectedInputContext - if (globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0) return true + const {globalMemory, commands, skills} = ctx.collectedInputContext + if (globalMemory != null || (commands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } @@ -76,7 +76,7 @@ export class CodexCLIOutputPlugin extends AbstractOutputPlugin { } async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands, skills} = ctx.collectedInputContext + const {globalMemory, commands, skills} = ctx.collectedInputContext const projectConfig = this.resolvePromptSourceProjectConfig(ctx) const fileResults: WriteResult[] = [] const globalDir = this.getGlobalConfigDir() @@ -87,10 +87,10 @@ export class CodexCLIOutputPlugin extends AbstractOutputPlugin { fileResults.push(result) } - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + if (commands != null && commands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(commands, projectConfig) for (const cmd of filteredCommands) { - const result = await this.writeGlobalFastCommand(ctx, globalDir, cmd) + const result = await this.writeGlobalCommand(ctx, globalDir, cmd) fileResults.push(result) } } @@ -105,13 +105,13 @@ export class CodexCLIOutputPlugin extends AbstractOutputPlugin { return {files: fileResults, dirs: []} } - private async writeGlobalFastCommand( + private async writeGlobalCommand( ctx: OutputWriteContext, globalDir: string, - cmd: FastCommandPrompt + cmd: CommandPrompt ): Promise { const transformOptions = this.getTransformOptionsFromContext(ctx) - const fileName = this.transformFastCommandName(cmd, transformOptions) + const fileName = this.transformCommandName(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') diff --git a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.projectConfig.test.ts deleted file mode 100644 index 3ae4e071..00000000 --- a/cli/src/plugins/plugin-opencode-cli/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' -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 deleted file mode 100644 index ce30d4e4..00000000 --- a/cli/src/plugins/plugin-opencode-cli/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(`-\\s+['"]?${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?`)) // Accept quoted or unquoted formats - }), {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 deleted file mode 100644 index f779d2ce..00000000 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts +++ /dev/null @@ -1,854 +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 return empty array since all outputs go to project level', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - expect(dirs).toHaveLength(0) - }) - }) - - 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 NOT register fast commands globally (only project level)', 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).toBeUndefined() - }) - - it('should NOT register agents globally (only project level)', 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).toBeUndefined() - }) - - it('should NOT register agents globally (mdx test)', 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).toBeUndefined() - }) - - it('should NOT register skills globally (only project level)', 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).toBeUndefined() - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should register project-level commands, agents, and skills', 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 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 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 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 ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - }, - fastCommands: [mockCmd], - subAgents: [mockAgent], - skills: [mockSkill] - } - } - - const files = await plugin.registerProjectOutputFiles(ctxWithProject) - - const cmdFile = files.find(f => f.path.includes('test-cmd.md')) // Check command - expect(cmdFile).toBeDefined() - expect(cmdFile?.path).toContain('commands') - - const agentFile = files.find(f => f.path.includes('test-agent.md')) // Check agent - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('agents') - - const skillFile = files.find(f => f.path.includes('SKILL.md')) // Check skill - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain('skills') - }) - }) - - describe('skill name normalization', () => { - it('should normalize skill names to opencode format at project level', 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 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 ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - }, - skills: [mockSkill] - } - } - - const files = await plugin.registerProjectOutputFiles(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('writeProjectOutputs sub-agent mdx regression', () => { - it('should write sub agent file with .md extension when source has .mdx at project level', 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 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, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - }, - subAgents: [mockAgent] - } - } - - const results = await plugin.writeProjectOutputs(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, 'project-a', '.config/opencode', 'agents', 'reviewer.cn.md') - expect(fs.existsSync(writtenPath)).toBe(true) - expect(fs.existsSync(path.join(tempDir, 'project-a', '.config/opencode', 'agents', 'reviewer.cn.mdx'))).toBe(false) - expect(fs.existsSync(path.join(tempDir, 'project-a', '.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).toMatch(/-\s+['"]?\*\*\/\*\.ts['"]?/) // Accept quoted or unquoted formats - expect(content).toMatch(/-\s+['"]?\*\*\/\*\.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 index 15845528..523c524b 100644 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts @@ -1,12 +1,14 @@ -import type {FastCommandPrompt, OutputPluginContext, OutputWriteContext, RulePrompt, SkillPrompt, SubAgentPrompt, WriteResult, WriteResults} from '@truenine/plugin-shared' +import type {CommandPrompt, 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 { applySubSeriesGlobPrefix, BaseCLIOutputPlugin, + filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig, + filterSubAgentsByProjectConfig, McpConfigManager, transformMcpConfigForOpencode } from '@truenine/plugin-output-shared' @@ -32,7 +34,7 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { commandsSubDir: 'commands', agentsSubDir: 'agents', skillsSubDir: 'skills', - supportsFastCommands: true, + supportsCommands: true, supportsSubAgents: true, supportsSkills: true, dependsOn: [PLUGIN_NAMES.AgentsOutput] @@ -127,24 +129,79 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { } override async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { - const baseResults = await super.registerProjectOutputFiles(ctx) + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace - const {rules} = ctx.collectedInputContext // Add project rules - if (rules != null && rules.length > 0) { - for (const project of ctx.collectedInputContext.workspace.projects) { - if (project.dirFromWorkspacePath == null) continue + for (const project of projects) { + if (project.rootMemoryPrompt != null && project.dirFromWorkspacePath != null) { + results.push(this.createFileRelativePath(project.dirFromWorkspacePath, this.outputFileName)) + } + + if (project.childMemoryPrompts != null) { + for (const child of project.childMemoryPrompts) { + if (child.dir != null && this.isRelativePath(child.dir)) results.push(this.createFileRelativePath(child.dir, this.outputFileName)) + } + } + + if (project.dirFromWorkspacePath == null) continue + + const {projectConfig} = project + const basePath = path.join(project.dirFromWorkspacePath.path, PROJECT_RULES_DIR) + const transformOptions = {includeSeriesPrefix: true} as const + + if (this.supportsCommands && ctx.collectedInputContext.commands != null) { + const filteredCommands = filterCommandsByProjectConfig(ctx.collectedInputContext.commands, projectConfig) + for (const cmd of filteredCommands) { + const fileName = this.transformCommandName(cmd, transformOptions) + results.push(this.createRelativePath(path.join(basePath, this.commandsSubDir, fileName), project.dirFromWorkspacePath.basePath, () => this.commandsSubDir)) + } + } + + if (this.supportsSubAgents && ctx.collectedInputContext.subAgents != null) { + const filteredSubAgents = filterSubAgentsByProjectConfig(ctx.collectedInputContext.subAgents, projectConfig) + for (const agent of filteredSubAgents) { + const fileName = agent.dir.path.replace(/\.mdx$/, '.md') + results.push(this.createRelativePath(path.join(basePath, this.agentsSubDir, fileName), project.dirFromWorkspacePath.basePath, () => this.agentsSubDir)) + } + } + + if (this.supportsSkills && ctx.collectedInputContext.skills != null) { + const filteredSkills = filterSkillsByProjectConfig(ctx.collectedInputContext.skills, projectConfig) + for (const skill of filteredSkills) { + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const skillDir = path.join(basePath, this.skillsSubDir, skillName) + + results.push(this.createRelativePath(path.join(skillDir, 'SKILL.md'), project.dirFromWorkspacePath.basePath, () => skillName)) + + if (skill.childDocs != null) { + for (const refDoc of skill.childDocs) { + const refDocFileName = refDoc.dir.path.replace(/\.mdx$/, '.md') + results.push(this.createRelativePath(path.join(skillDir, refDocFileName), project.dirFromWorkspacePath.basePath, () => skillName)) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + results.push(this.createRelativePath(path.join(skillDir, resource.relativePath), project.dirFromWorkspacePath.basePath, () => skillName)) + } + } + } + } + + const {rules} = ctx.collectedInputContext // Add project rules + if (rules != null && rules.length > 0) { const projectRules = applySubSeriesGlobPrefix( filterRulesByProjectConfig(rules, project.projectConfig), project.projectConfig ) for (const rule of projectRules) { const filePath = path.join(project.dirFromWorkspacePath.path, PROJECT_RULES_DIR, RULES_SUBDIR, this.buildRuleFileName(rule)) - baseResults.push(this.createRelativePath(filePath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) + results.push(this.createRelativePath(filePath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) } } } - return baseResults.map(result => { // Normalize skill directory names in paths for opencode format + return results.map(result => { const normalizedPath = result.path.replaceAll('\\', '/') const skillsPatternWithSlash = `/${this.skillsSubDir}/` const skillsPatternStart = `${this.skillsSubDir}/` @@ -281,23 +338,23 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { return frontMatter } - protected override async writeFastCommand( + protected override async writeCommand( ctx: OutputWriteContext, basePath: string, - cmd: FastCommandPrompt + cmd: CommandPrompt ): Promise { const transformOptions = this.getTransformOptionsFromContext(ctx) - const fileName = this.transformFastCommandName(cmd, transformOptions) + const fileName = this.transformCommandName(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')] + return [await this.writeFile(ctx, fullPath, content, 'command')] } - private buildOpencodeCommandFrontMatter(cmd: FastCommandPrompt): Record { + private buildOpencodeCommandFrontMatter(cmd: CommandPrompt): Record { const frontMatter: Record = {} const source = cmd.yamlFrontMatter as Record | undefined @@ -410,21 +467,37 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { } 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) { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + + const subdirs: string[] = [] + if (this.supportsCommands) subdirs.push(this.commandsSubDir) + if (this.supportsSubAgents) subdirs.push(this.agentsSubDir) + if (this.supportsSkills) subdirs.push(this.skillsSubDir) + + for (const project of projects) { if (project.dirFromWorkspacePath == null) continue - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => this.normalizeRuleScope(r) === 'project'), + for (const subdir of subdirs) { + const dirPath = path.join(project.dirFromWorkspacePath.path, PROJECT_RULES_DIR, subdir) + results.push(this.createRelativePath(dirPath, project.dirFromWorkspacePath.basePath, () => subdir)) + } + } + + const {rules} = ctx.collectedInputContext + if (rules != null && rules.length > 0) { + 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 - ), - 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)) + ) + 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 } @@ -435,23 +508,72 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { } 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 = [] + const fileResults: WriteResult[] = [] + const dirResults: WriteResult[] = [] + for (const project of ctx.collectedInputContext.workspace.projects) { if (project.dirFromWorkspacePath == null) continue - const projectRules = applySubSeriesGlobPrefix( - filterRulesByProjectConfig( - rules.filter(r => this.normalizeRuleScope(r) === 'project'), + + const projectDir = project.dirFromWorkspacePath + const {projectConfig} = project + const basePath = path.join(projectDir.basePath, projectDir.path, PROJECT_RULES_DIR) + + if (project.rootMemoryPrompt != null) { + const result = await this.writePromptFile(ctx, projectDir, project.rootMemoryPrompt.content as string, `project:${project.name}/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:${project.name}/child:${child.workingChildDirectoryPath?.path ?? 'unknown'}`) + fileResults.push(childResult) + } + } + + if (this.supportsCommands && ctx.collectedInputContext.commands != null) { + const filteredCommands = filterCommandsByProjectConfig(ctx.collectedInputContext.commands, projectConfig) + for (const cmd of filteredCommands) { + const cmdResults = await this.writeCommand(ctx, basePath, cmd) + fileResults.push(...cmdResults) + } + } + + if (this.supportsSubAgents && ctx.collectedInputContext.subAgents != null) { + const filteredSubAgents = filterSubAgentsByProjectConfig(ctx.collectedInputContext.subAgents, projectConfig) + for (const agent of filteredSubAgents) { + const agentResults = await this.writeSubAgent(ctx, basePath, agent) + fileResults.push(...agentResults) + } + } + + if (this.supportsSkills && ctx.collectedInputContext.skills != null) { + const filteredSkills = filterSkillsByProjectConfig(ctx.collectedInputContext.skills, projectConfig) + for (const skill of filteredSkills) { + const skillResults = await this.writeSkill(ctx, basePath, skill) + fileResults.push(...skillResults) + } + } + } + + const {rules} = ctx.collectedInputContext + if (rules != null && rules.length > 0) { + 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 - ), - 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')) + ) + 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) { + fileResults.push(await this.writeFile(ctx, path.join(rulesDir, this.buildRuleFileName(rule)), this.buildRuleContent(rule), 'rule')) + } + } } - return {files: [...results.files, ...ruleResults], dirs: results.dirs} + + return {files: fileResults, dirs: dirResults} } } diff --git a/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.test.ts b/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.test.ts deleted file mode 100644 index 04dd185e..00000000 --- a/cli/src/plugins/plugin-output-shared/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/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts b/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts index a7692193..b4f268a5 100644 --- a/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts +++ b/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts @@ -1,5 +1,5 @@ -import type {CleanEffectHandler, EffectRegistration, EffectResult, FastCommandPrompt, ILogger, OutputCleanContext, OutputPlugin, OutputPluginContext, OutputWriteContext, Project, RegistryOperationResult, RulePrompt, RuleScope, SkillPrompt, WriteEffectHandler, WriteResult, WriteResults} from '@truenine/plugin-shared' -import type {FastCommandSeriesPluginOverride, Path, ProjectConfig, RegistryData, RelativePath} from '@truenine/plugin-shared/types' +import type {CleanEffectHandler, CommandPrompt, CommandSeriesPluginOverride, EffectRegistration, EffectResult, ILogger, OutputCleanContext, OutputPlugin, OutputPluginContext, OutputWriteContext, Project, RegistryOperationResult, RulePrompt, RuleScope, SkillPrompt, WriteEffectHandler, WriteResult, WriteResults} from '@truenine/plugin-shared' +import type {Path, ProjectConfig, RegistryData, RelativePath} from '@truenine/plugin-shared/types' import type {Buffer} from 'node:buffer' import type {RegistryWriter} from './registry/RegistryWriter' @@ -65,10 +65,10 @@ export interface ErrorContext { } /** - * Options for transforming fast command names in output filenames. - * Used by transformFastCommandName method to control prefix handling. + * Options for transforming command names in output filenames. + * Used by transformCommandName method to control prefix handling. */ -export interface FastCommandNameTransformOptions { +export interface CommandNameTransformOptions { readonly includeSeriesPrefix?: boolean readonly seriesSeparator?: string } @@ -490,9 +490,9 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin