diff --git a/.github/actions/setup-tauri/action.yml b/.github/actions/setup-tauri/action.yml index 210e75f5..ca600d1c 100644 --- a/.github/actions/setup-tauri/action.yml +++ b/.github/actions/setup-tauri/action.yml @@ -18,7 +18,10 @@ runs: shell: bash run: | sudo apt-get update - sudo apt-get install -y \ + sudo apt-get install -y --no-install-recommends \ + libgtk-3-dev \ + libglib2.0-dev \ + pkg-config \ libwebkit2gtk-4.1-dev \ libappindicator3-dev \ librsvg2-dev \ @@ -48,7 +51,7 @@ runs: restore-keys: | ${{ runner.os }}-cargo- - - name: Sync Tauri version from CLI + - name: Sync Tauri version shell: bash run: | version="${{ inputs.version }}" @@ -63,4 +66,7 @@ runs: - name: Clean old bundle artifacts shell: bash - run: rm -rf gui/src-tauri/target/**/bundle + run: | + if [ -d "gui/src-tauri/target" ]; then + find gui/src-tauri/target -type d -name bundle -prune -exec rm -rf {} + + fi diff --git a/.github/workflows/build-gui-all.yml b/.github/workflows/build-gui-all.yml index ef9988be..6a31a4e4 100644 --- a/.github/workflows/build-gui-all.yml +++ b/.github/workflows/build-gui-all.yml @@ -18,16 +18,23 @@ jobs: include: - os: windows-latest platform: windows + rust-targets: '' + tauri-command: pnpm tauri build - os: ubuntu-24.04 platform: linux + rust-targets: '' + tauri-command: pnpm tauri build - os: macos-14 platform: macos + rust-targets: aarch64-apple-darwin,x86_64-apple-darwin + tauri-command: pnpm tauri build --target universal-apple-darwin runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-node-pnpm - uses: ./.github/actions/setup-tauri with: + rust-targets: ${{ matrix.rust-targets }} version: ${{ inputs.version }} - name: Build GUI @@ -35,7 +42,7 @@ jobs: env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: pnpm tauri build + run: ${{ matrix.tauri-command }} - name: Upload Windows artifacts if: matrix.platform == 'windows' @@ -43,8 +50,8 @@ jobs: with: name: gui-${{ matrix.os }} path: | - target/*/release/bundle/**/*.exe - target/release/bundle/**/*.exe + gui/src-tauri/target/*/release/bundle/**/*.exe + gui/src-tauri/target/release/bundle/**/*.exe if-no-files-found: error - name: Upload Linux artifacts @@ -53,12 +60,12 @@ jobs: with: name: gui-${{ matrix.os }} path: | - target/*/release/bundle/**/*.AppImage - target/*/release/bundle/**/*.deb - target/*/release/bundle/**/*.rpm - target/release/bundle/**/*.AppImage - target/release/bundle/**/*.deb - target/release/bundle/**/*.rpm + gui/src-tauri/target/*/release/bundle/**/*.AppImage + gui/src-tauri/target/*/release/bundle/**/*.deb + gui/src-tauri/target/*/release/bundle/**/*.rpm + gui/src-tauri/target/release/bundle/**/*.AppImage + gui/src-tauri/target/release/bundle/**/*.deb + gui/src-tauri/target/release/bundle/**/*.rpm if-no-files-found: error - name: Upload macOS artifacts @@ -67,6 +74,6 @@ jobs: with: name: gui-${{ matrix.os }} path: | - target/*/release/bundle/**/*.dmg - target/release/bundle/**/*.dmg + gui/src-tauri/target/*/release/bundle/**/*.dmg + gui/src-tauri/target/release/bundle/**/*.dmg if-no-files-found: error diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ff111b3d..a573f67a 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -4,7 +4,7 @@ on: pull_request: branches: - main - types: [opened, synchronize, reopened, closed] + types: [opened, synchronize, reopened, ready_for_review] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} @@ -13,39 +13,26 @@ concurrency: jobs: check: runs-on: ubuntu-24.04 - if: github.event.pull_request.merged == false steps: - uses: actions/checkout@v4 - - name: Cache apt packages - uses: actions/cache@v4 - with: - path: /var/cache/apt/archives - key: apt-gtk-${{ runner.os }}-${{ hashFiles('.github/workflows/pull-request.yml') }} - restore-keys: apt-gtk-${{ runner.os }}- - - - name: Install GTK development dependencies - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libglib2.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf pkg-config - - uses: ./.github/actions/setup-node-pnpm - - name: Build - run: pnpm exec turbo run build - - uses: ./.github/actions/setup-rust with: cache-key: pr - - name: Generate Tauri icons - run: pnpm -F @truenine/memory-sync-gui run generate:icons + - name: Build + run: pnpm run build + + - name: Lint + run: pnpm run lint - - name: Generate route tree - run: pnpm -F @truenine/memory-sync-gui run generate:routes + - name: Typecheck + run: pnpm run typecheck - name: Run tests - run: pnpm exec turbo run test + run: pnpm run test - name: Rust tests (excluding GUI) run: cargo test --workspace --exclude memory-sync-gui --lib --bins --tests diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 7ba11244..fbb15137 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -22,6 +22,9 @@ jobs: version: ${{ steps.check.outputs.version }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 25 - name: Check if should publish id: check @@ -103,20 +106,31 @@ jobs: sudo apt-get update sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV - - name: Build all napi libraries + - name: Build all napi native modules shell: bash run: | - for lib in logger md-compiler config init-bundle; do - echo "Building napi for $lib..." - (cd "libraries/$lib" && pnpm exec napi build --platform --release --target ${{ matrix.target.rust }} --output-dir dist -- --features napi) + module_dirs=(libraries/logger libraries/md-compiler cli) + for module_dir in "${module_dirs[@]}"; do + echo "Building napi in ${module_dir}..." + ( + cd "${module_dir}" && \ + pnpm exec napi build --platform --release --target ${{ matrix.target.rust }} --output-dir dist -- --features napi + ) done - name: Collect .node files into CLI platform package shell: bash run: | target_dir="cli/npm/${{ matrix.target.suffix }}" mkdir -p "$target_dir" - for lib in logger md-compiler config init-bundle; do - cp libraries/$lib/dist/*.node "$target_dir/" + shopt -s nullglob + module_dirs=(libraries/logger libraries/md-compiler cli) + for module_dir in "${module_dirs[@]}"; do + node_files=("${module_dir}"/dist/*.node) + if [ "${#node_files[@]}" -eq 0 ]; then + echo "ERROR: no .node files found in ${module_dir}/dist" + exit 1 + fi + cp "${node_files[@]}" "$target_dir/" done echo "Contents of $target_dir:" ls -la "$target_dir/" @@ -185,7 +199,7 @@ jobs: node-version: 25 registry-url: https://registry.npmjs.org/ - name: Build - run: pnpm exec turbo run build --filter=@truenine/memory-sync-cli... + run: pnpm -F @truenine/memory-sync-cli run build - name: Publish to npm working-directory: ./cli run: pnpm publish --access public --no-git-checks @@ -228,7 +242,7 @@ jobs: - name: Build plugin-runtime shell: bash run: | - pnpm exec turbo run build --filter=@truenine/memory-sync-cli... + pnpm -F @truenine/memory-sync-cli run build ls -la cli/dist/plugin-runtime.mjs - uses: ./.github/actions/setup-rust with: diff --git a/Cargo.lock b/Cargo.lock index b3fbe2cb..e5c544bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10302.10037" +version = "2026.10303.10919" dependencies = [ "dirs", "proptest", @@ -4443,61 +4443,18 @@ version = "2026.10222.0" dependencies = [ "clap", "dirs", - "proptest", - "reqwest", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "tnmsc-config", - "tnmsc-init-bundle", - "tnmsc-input-plugins", - "tnmsc-logger", - "tnmsc-md-compiler", - "tnmsc-plugin-shared", -] - -[[package]] -name = "tnmsc-config" -version = "2026.10222.0" -dependencies = [ - "dirs", - "napi", - "napi-build", - "napi-derive", - "serde", - "serde_json", - "sha2", - "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" -dependencies = [ - "base64 0.22.1", - "glob", "napi", "napi-build", "napi-derive", + "proptest", + "reqwest", "serde", "serde_json", "sha2", - "tnmsc-config", - "tnmsc-init-bundle", + "tempfile", + "thiserror 2.0.18", "tnmsc-logger", "tnmsc-md-compiler", - "tnmsc-plugin-shared", ] [[package]] @@ -4526,14 +4483,6 @@ dependencies = [ "tnmsc-logger", ] -[[package]] -name = "tnmsc-plugin-shared" -version = "2026.10222.0" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "tokio" version = "1.49.0" diff --git a/Cargo.toml b/Cargo.toml index 06308e73..d91b17fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,6 @@ members = [ "cli", "libraries/logger", "libraries/md-compiler", - "libraries/config", - "libraries/plugin-shared", - "libraries/input-plugins", - "libraries/init-bundle", "gui/src-tauri", ] @@ -23,10 +19,6 @@ repository = "https://github.com/TrueNine/memory-sync" tnmsc = { path = "cli" } tnmsc-logger = { path = "libraries/logger" } 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..33d523bd 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -10,6 +10,7 @@ repository.workspace = true [lib] name = "tnmsc" path = "src/lib.rs" +crate-type = ["rlib", "cdylib"] [[bin]] name = "tnmsc" @@ -18,22 +19,24 @@ path = "src/main.rs" [features] default = [] embedded-runtime = [] +napi = ["dep:napi", "dep:napi-derive"] [dependencies] tnmsc-logger = { workspace = true } 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" clap = { workspace = true } dirs = { workspace = true } +sha2 = { workspace = true } +napi = { workspace = true, optional = true } +napi-derive = { workspace = true, optional = true } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls", "rustls-native-certs"] } [dev-dependencies] proptest = "1" tempfile = "3" -tnmsc-config = { workspace = true } + +[build-dependencies] +napi-build = { workspace = true } diff --git a/cli/build.rs b/cli/build.rs index 377f60bc..f2be9938 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -1,29 +1,4 @@ -use std::env; -use std::fs; -use std::path::Path; - fn main() { - // Only process embedded-runtime when the feature is enabled - if env::var("CARGO_FEATURE_EMBEDDED_RUNTIME").is_ok() { - let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); - let dest = Path::new(&out_dir).join("plugin-runtime.mjs"); - - // Look for plugin-runtime.mjs relative to the crate root - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); - let source = Path::new(&manifest_dir).join("dist/plugin-runtime.mjs"); - - if source.exists() { - fs::copy(&source, &dest).expect("Failed to copy plugin-runtime.mjs to OUT_DIR"); - println!("cargo:rerun-if-changed={}", source.display()); - } else { - // Write empty placeholder so include_str! doesn't fail - fs::write(&dest, "").expect("Failed to write empty plugin-runtime.mjs"); - println!( - "cargo:warning=plugin-runtime.mjs not found at {}. Build with 'pnpm -F @truenine/memory-sync-cli run bundle' first.", - source.display() - ); - } - - println!("cargo:rerun-if-changed=dist/plugin-runtime.mjs"); - } + #[cfg(feature = "napi")] + napi_build::setup(); } 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/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index ddf9caad..3dc57217 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.10919", "os": [ "darwin" ], diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index 0308e7fc..112c91d2 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.10919", "os": [ "darwin" ], diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index 9b7bb142..e1d668ac 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.10919", "os": [ "linux" ], diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 3b7a52b9..2a5dba79 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.10919", "os": [ "linux" ], diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index f296a87c..64a6a941 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.10919", "os": [ "win32" ], diff --git a/cli/package.json b/cli/package.json index 81e90ded..7ea6e4b1 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.10919", "description": "TrueNine Memory Synchronization CLI", "author": "TrueNine", "license": "AGPL-3.0-only", @@ -34,6 +34,16 @@ "dist", "dist/tnmsc.schema.json" ], + "napi": { + "binaryName": "napi-memory-sync-cli", + "targets": [ + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "aarch64-apple-darwin", + "x86_64-apple-darwin" + ] + }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" @@ -62,7 +72,7 @@ "picocolors": "catalog:", "picomatch": "catalog:", "tsx": "4.21.0", - "vitest": "^4.0.18", + "vitest": "catalog:", "yaml": "2.8.2", "zod": "catalog:" }, @@ -74,7 +84,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/Aindex.ts b/cli/src/Aindex.ts new file mode 100644 index 00000000..8924cbbf --- /dev/null +++ b/cli/src/Aindex.ts @@ -0,0 +1,90 @@ +/** + * Aindex validation and generation utilities + * 使用扁平的 bundles 结构直接遍历创建项目目录和文件 + */ +import type {ILogger} from './plugins/plugin-shared' +import * as fs from 'node:fs' +import * as path from 'node:path' + +/** + * Version control check result + */ +export interface VersionControlCheckResult { + readonly hasGit: boolean + readonly gitPath: string +} + +/** + * Check if the aindex has version control (.git directory) + * Logs info if .git exists, warns if not + * + * @param rootPath - Root path of the aindex + * @param logger - Optional logger instance + * @returns Version control check result + */ +export function checkVersionControl( + rootPath: string, + logger?: ILogger +): VersionControlCheckResult { + const gitPath = path.join(rootPath, '.git') + const hasGit = fs.existsSync(gitPath) + + if (hasGit) logger?.info('version control detected', {path: gitPath}) + else logger?.warn('no version control detected, please use git to manage your aindex', {path: rootPath}) + + return {hasGit, gitPath} +} + +/** + * Generation result + */ +export interface GenerationResult { + readonly success: boolean + readonly rootPath: string + readonly createdDirs: readonly string[] + readonly createdFiles: readonly string[] + readonly existedDirs: readonly string[] + readonly existedFiles: readonly string[] +} + +/** + * Generation options + */ +export interface GenerationOptions { + /** Logger instance */ + readonly logger?: ILogger +} + +/** + * Generate aindex directory structure + */ +export function generateAindex( + rootPath: string, + options: GenerationOptions = {} +): GenerationResult { + const {logger} = options + const createdDirs: string[] = [] + const createdFiles: string[] = [] + const existedDirs: string[] = [] + const existedFiles: string[] = [] + const createdDirsSet = new Set() + + if (fs.existsSync(rootPath)) { + existedDirs.push(rootPath) + logger?.debug('directory exists', {path: rootPath}) + } else { + fs.mkdirSync(rootPath, {recursive: true}) + createdDirs.push(rootPath) + createdDirsSet.add(rootPath) + logger?.info('created directory', {path: rootPath}) + } + + return { + success: true, + rootPath, + createdDirs, + createdFiles, + existedDirs, + existedFiles + } +} diff --git a/cli/src/ConfigLoader.test.ts b/cli/src/ConfigLoader.test.ts deleted file mode 100644 index 2363ec0a..00000000 --- a/cli/src/ConfigLoader.test.ts +++ /dev/null @@ -1,330 +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 handle invalid JSON gracefully', () => { - 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({}) - }) - - it('should validate 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() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.workspaceDir).toBeUndefined() // Invalid field should be ignored - }) - - it('should validate 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() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.logLevel).toBeUndefined() - }) - - 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() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.shadowSourceProject).toBeUndefined() - }) - - 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() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.profile).toBeUndefined() - }) - - 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() - const result = loader.loadFromFile('/test/.tnmsc.json') - - expect(result.found).toBe(true) - expect(result.config.profile).toBeUndefined() - }) - }) - - describe('load', () => { - it('should return empty config when no 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([]) - }) - - 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 use default loader', () => { - vi.mocked(fs.existsSync).mockReturnValue(false) - - const result = loadUserConfig(mockCwd) - - expect(result.found).toBe(false) - expect(result.config).toEqual({}) - }) - }) -}) diff --git a/cli/src/ConfigLoader.ts b/cli/src/ConfigLoader.ts index d002b1db..299277aa 100644 --- a/cli/src/ConfigLoader.ts +++ b/cli/src/ConfigLoader.ts @@ -1,9 +1,9 @@ -import type {ConfigLoaderOptions, ConfigLoadResult, ILogger, ShadowSourceProjectConfig, UserConfigFile} from '@truenine/plugin-shared' +import type {AindexConfig, ConfigLoaderOptions, ConfigLoadResult, ILogger, UserConfigFile} from './plugins/plugin-shared' 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 './plugins/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 { @@ -156,32 +150,34 @@ export class ConfigLoader { const reversed = [...configs].reverse() // Reverse to merge from lowest to highest priority return reversed.reduce((acc, config) => { - const mergedShadowSourceProject = this.mergeShadowSourceProject(acc.shadowSourceProject, config.shadowSourceProject) + const mergedAindex = this.mergeAindex(acc.aindex, config.aindex) return { ...acc, ...config, - ...mergedShadowSourceProject != null ? {shadowSourceProject: mergedShadowSourceProject} : {} + ...mergedAindex != null ? {aindex: mergedAindex} : {} } }, {}) } - private mergeShadowSourceProject( - a?: ShadowSourceProjectConfig, - b?: ShadowSourceProjectConfig - ): ShadowSourceProjectConfig | undefined { + private mergeAindex( + a?: AindexConfig, + b?: AindexConfig + ): AindexConfig | undefined { if (a == null && b == null) return void 0 if (a == null) return b if (b == null) return a return { - name: b.name ?? a.name, - skill: {...a.skill, ...b.skill}, - fastCommand: {...a.fastCommand, ...b.fastCommand}, - subAgent: {...a.subAgent, ...b.subAgent}, - rule: {...a.rule, ...b.rule}, - globalMemory: {...a.globalMemory, ...b.globalMemory}, - workspaceMemory: {...a.workspaceMemory, ...b.workspaceMemory}, - project: {...a.project, ...b.project} + dir: b.dir ?? a.dir, + skills: {...a.skills, ...b.skills}, + commands: {...a.commands, ...b.commands}, + subAgents: {...a.subAgents, ...b.subAgents}, + rules: {...a.rules, ...b.rules}, + globalPrompt: {...a.globalPrompt, ...b.globalPrompt}, + workspacePrompt: {...a.workspacePrompt, ...b.workspacePrompt}, + app: {...a.app, ...b.app}, + ext: {...a.ext, ...b.ext}, + arch: {...a.arch, ...b.arch} } } @@ -224,56 +220,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 +301,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.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 fc1cfd19..00000000 --- a/cli/src/PluginPipeline.test.ts +++ /dev/null @@ -1,828 +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, - InitCommand, - 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('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'}) - 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/PluginPipeline.ts b/cli/src/PluginPipeline.ts index ae3361eb..b8d030bf 100644 --- a/cli/src/PluginPipeline.ts +++ b/cli/src/PluginPipeline.ts @@ -1,296 +1,49 @@ 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 './plugins/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 glob from 'fast-glob' import { - CleanCommand, - ConfigCommand, - ConfigShowCommand, - DryRunCleanCommand, - DryRunOutputCommand, - ExecuteCommand, - HelpCommand, - InitCommand, - JsonOutputCommand, - OutdatedCommand, - PluginsCommand, - UnknownCommand, - VersionCommand -} from '@/commands' + buildDependencyContext, + extractUserArgs, + mergeContexts, + parseArgs, + + resolveCommand, + resolveLogLevel, + topologicalSort +} from '@/pipeline' import {startupVersionCheck} from '@/versionCheck' +import {createLogger, setGlobalLogLevel} from './plugins/plugin-shared' + +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' /** - * 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] -]) - -/** - * 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 + * 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 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 - */ -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 +77,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,128 +122,6 @@ 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 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) - } - - 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 - } - async executePluginsInOrder( plugins: readonly InputPlugin[], baseCtx: Omit, @@ -499,7 +130,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 +164,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) { @@ -552,131 +183,6 @@ export class PluginPipeline { plugin: InputPlugin, outputsByPlugin: Map> ): Partial { - const deps = plugin.dependsOn ?? [] - if (deps.length === 0) return {} - - const allDeps = this.collectTransitiveDependencies(plugin, outputsByPlugin) // Collect all transitive dependencies - - 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) - } - - return merged - } - - private collectTransitiveDependencies( - plugin: InputPlugin, - 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) // 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 - if (depOutput != null) result.push(dep) - } - } - - 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} : {} - } + return buildDependencyContext(plugin, outputsByPlugin, mergeContexts) } } diff --git a/cli/src/ShadowSourceProject.ts b/cli/src/ShadowSourceProject.ts deleted file mode 100644 index d2286047..00000000 --- a/cli/src/ShadowSourceProject.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Shadow Source Project validation and generation utilities - * 使用扁平的 bundles 结构直接遍历创建项目目录和文件 - */ -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 - */ -export interface VersionControlCheckResult { - readonly hasGit: boolean - readonly gitPath: string -} - -/** - * Check if the shadow source project has version control (.git directory) - * Logs info if .git exists, warns if not - * - * @param rootPath - Root path of the shadow source project - * @param logger - Optional logger instance - * @returns Version control check result - */ -export function checkVersionControl( - rootPath: string, - logger?: ILogger -): VersionControlCheckResult { - const gitPath = path.join(rootPath, '.git') - const hasGit = fs.existsSync(gitPath) - - if (hasGit) logger?.info('version control detected', {path: gitPath}) - else logger?.warn('no version control detected, please use git to manage your shadow source project', {path: rootPath}) - - return {hasGit, gitPath} -} - -/** - * Generation result - */ -export interface GenerationResult { - readonly success: boolean - readonly rootPath: string - readonly createdDirs: readonly string[] - readonly createdFiles: readonly string[] - readonly existedDirs: readonly string[] - readonly existedFiles: readonly string[] -} - -/** - * 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 createdDirs: string[] = [] - const createdFiles: string[] = [] - const existedDirs: string[] = [] - const existedFiles: string[] = [] - const createdDirsSet = new Set() // Track created directories to avoid duplicates - - if (fs.existsSync(rootPath)) { // Ensure root directory exists - existedDirs.push(rootPath) - logger?.debug('directory exists', {path: rootPath}) - } else { - fs.mkdirSync(rootPath, {recursive: true}) - createdDirs.push(rootPath) - createdDirsSet.add(rootPath) - 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, - createdDirs, - createdFiles, - existedDirs, - existedFiles - } -} 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/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/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..b54c340d --- /dev/null +++ b/cli/src/commands/CommandRegistryFactory.ts @@ -0,0 +1,35 @@ +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 {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 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..6af4e118 100644 --- a/cli/src/commands/ConfigCommand.ts +++ b/cli/src/commands/ConfigCommand.ts @@ -6,25 +6,28 @@ import {DEFAULT_CONFIG_FILE_NAME, DEFAULT_GLOBAL_CONFIG_DIR} from '@/ConfigLoade /** * Valid configuration keys that can be set via `tnmsc config key=value`. - * Nested keys use dot-notation: shadowSourceProject.name, shadowSourceProject.skill.src, etc. + * Nested keys use dot-notation: aindex.skills.src, aindex.commands.src, etc. */ const VALID_CONFIG_KEYS = [ 'workspaceDir', - 'shadowSourceProject.name', - 'shadowSourceProject.skill.src', - 'shadowSourceProject.skill.dist', - 'shadowSourceProject.fastCommand.src', - 'shadowSourceProject.fastCommand.dist', - 'shadowSourceProject.subAgent.src', - 'shadowSourceProject.subAgent.dist', - 'shadowSourceProject.rule.src', - 'shadowSourceProject.rule.dist', - 'shadowSourceProject.globalMemory.src', - 'shadowSourceProject.globalMemory.dist', - 'shadowSourceProject.workspaceMemory.src', - 'shadowSourceProject.workspaceMemory.dist', - 'shadowSourceProject.project.src', - 'shadowSourceProject.project.dist', + 'aindex.skills.src', + 'aindex.skills.dist', + 'aindex.commands.src', + 'aindex.commands.dist', + 'aindex.subAgents.src', + 'aindex.subAgents.dist', + 'aindex.rules.src', + 'aindex.rules.dist', + 'aindex.globalPrompt.src', + 'aindex.globalPrompt.dist', + 'aindex.workspacePrompt.src', + 'aindex.workspacePrompt.dist', + 'aindex.app.src', + 'aindex.app.dist', + 'aindex.ext.src', + 'aindex.ext.dist', + 'aindex.arch.src', + 'aindex.arch.dist', 'logLevel' ] as const @@ -55,12 +58,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 +73,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 +82,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 +105,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/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 ec4fc18c..00000000 --- a/cli/src/commands/HelpCommand.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import {createLogger} from '@truenine/plugin-shared' -import {describe, expect, it, vi} from 'vitest' -import {HelpCommand} from './HelpCommand' - -const mockLogger = createLogger('test', 'error') - -describe('helpCommand', () => { - describe('help text content', () => { - it('should list all subcommands', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }) - - const command = new HelpCommand() - await command.execute({logger: mockLogger} as any) - - const helpText = consoleSpy.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 command = new HelpCommand() - await command.execute({logger: mockLogger} as any) - - const helpText = consoleSpy.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 command = new HelpCommand() - await command.execute({logger: mockLogger} as any) - - const helpText = consoleSpy.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 command = new HelpCommand() - await command.execute({logger: mockLogger} as any) - - const helpText = consoleSpy.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 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) - - consoleSpy.mockRestore() - }) - }) -}) diff --git a/cli/src/commands/HelpCommand.ts b/cli/src/commands/HelpCommand.ts index 870d0d19..2ceb187b 100644 --- a/cli/src/commands/HelpCommand.ts +++ b/cli/src/commands/HelpCommand.ts @@ -47,18 +47,19 @@ CLEAN OPTIONS: CONFIG OPTIONS: key=value Set a configuration value in global config (~/.aindex/.tnmsc.json) Valid keys: workspaceDir, logLevel, - shadowSourceProject.name, - shadowSourceProject.skill.src, shadowSourceProject.skill.dist, - shadowSourceProject.fastCommand.src, shadowSourceProject.fastCommand.dist, - shadowSourceProject.subAgent.src, shadowSourceProject.subAgent.dist, - shadowSourceProject.rule.src, shadowSourceProject.rule.dist, - shadowSourceProject.globalMemory.src, shadowSourceProject.globalMemory.dist, - shadowSourceProject.workspaceMemory.src, shadowSourceProject.workspaceMemory.dist, - shadowSourceProject.project.src, shadowSourceProject.project.dist + aindex.skills.src, aindex.skills.dist, + aindex.commands.src, aindex.commands.dist, + aindex.subAgents.src, aindex.subAgents.dist, + aindex.rules.src, aindex.rules.dist, + aindex.globalPrompt.src, aindex.globalPrompt.dist, + aindex.workspacePrompt.src, aindex.workspacePrompt.dist, + aindex.app.src, aindex.app.dist, + aindex.ext.src, aindex.ext.dist, + aindex.arch.src, aindex.arch.dist Examples: ${CLI_NAME} config workspaceDir=~/my-project - ${CLI_NAME} config shadowSourceProject.name=aindex + ${CLI_NAME} config aindex.skills.src=skills ${CLI_NAME} config logLevel=debug CONFIGURATION: @@ -72,9 +73,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/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/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/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/config_cmd.rs b/cli/src/commands/config_cmd.rs index f56fa4aa..f41802a1 100644 --- a/cli/src/commands/config_cmd.rs +++ b/cli/src/commands/config_cmd.rs @@ -1,7 +1,7 @@ use std::process::ExitCode; -use tnmsc_config::ConfigLoader; use tnmsc_logger::create_logger; +use crate::core::config::{ConfigLoader, get_global_config_path}; pub fn execute(pairs: &[(String, String)]) -> ExitCode { let logger = create_logger("config", None); @@ -32,7 +32,7 @@ pub fn execute(pairs: &[(String, String)]) -> ExitCode { } } - let config_path = tnmsc_config::get_global_config_path(); + let config_path = get_global_config_path(); match serde_json::to_string_pretty(&config) { Ok(json) => { if let Some(parent) = config_path.parent() { diff --git a/cli/src/commands/config_show.rs b/cli/src/commands/config_show.rs index 44f1f8bf..5f1d179d 100644 --- a/cli/src/commands/config_show.rs +++ b/cli/src/commands/config_show.rs @@ -1,7 +1,7 @@ use std::process::ExitCode; -use tnmsc_config::ConfigLoader; use tnmsc_logger::create_logger; +use crate::core::config::ConfigLoader; pub fn execute() -> ExitCode { let logger = create_logger("config-show", None); 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/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..4c1e174e --- /dev/null +++ b/cli/src/commands/factories/index.ts @@ -0,0 +1,38 @@ +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 { + OutdatedCommandFactory +} from './OutdatedCommandFactory' +export { + PluginsCommandFactory +} from './PluginsCommandFactory' +export { + UnknownCommandFactory +} from './UnknownCommandFactory' +export { + VersionCommandFactory +} from './VersionCommandFactory' // Factory implementations 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 1d057e76..df10df91 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -1,16 +1,30 @@ 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/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/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 9fdb0b27..625098c5 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -1,12 +1,12 @@ -import type {CollectedInputContext, ConfigLoaderOptions, FastCommandSeriesOptions, FastCommandSeriesPluginOverride, InputPlugin, InputPluginContext, OutputPlugin, PluginOptions, ShadowSourceProjectConfig, UserConfigFile} from '@truenine/plugin-shared' +import type {AindexConfig, CollectedInputContext, CommandSeriesOptions, CommandSeriesPluginOverride, ConfigLoaderOptions, InputPlugin, InputPluginContext, OutputPlugin, PluginOptions, UserConfigFile} from './plugins/plugin-shared' 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 glob from 'fast-glob' -import {loadUserConfig, validateAndEnsureGlobalConfig} from './ConfigLoader' +import {checkVersionControl} from './Aindex' +import {loadUserConfig, validateGlobalConfig} from './ConfigLoader' import {PluginPipeline} from './PluginPipeline' -import {checkVersionControl} from './ShadowSourceProject' +import {createLogger, PluginKind} from './plugins/plugin-shared' /** * Pipeline configuration containing collected context and output plugins @@ -17,23 +17,25 @@ export interface PipelineConfig { readonly userConfigOptions: Required } -const DEFAULT_SHADOW_SOURCE_PROJECT: Required = { - 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'} +const DEFAULT_AINDEX: Required = { + dir: 'aindex', + skills: {src: 'skills', dist: 'dist/skills'}, + commands: {src: 'commands', dist: 'dist/commands'}, + subAgents: {src: 'subagents', dist: 'dist/subagents'}, + rules: {src: 'rules', dist: 'dist/rules'}, + globalPrompt: {src: 'global.cn.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: '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'} } const DEFAULT_OPTIONS: Required = { - version: DEFAULT_USER_CONFIG.version ?? '0.0.0', - workspaceDir: DEFAULT_USER_CONFIG.workspaceDir ?? '~/project', - logLevel: DEFAULT_USER_CONFIG.logLevel ?? 'info', - shadowSourceProject: DEFAULT_SHADOW_SOURCE_PROJECT, - fastCommandSeriesOptions: {}, + version: '0.0.0', + workspaceDir: '~/project', + logLevel: 'info', + aindex: DEFAULT_AINDEX, + commandSeriesOptions: {}, plugins: [] } @@ -45,8 +47,8 @@ function userConfigToPluginOptions(userConfig: UserConfigFile): Partial ): Required { const overridePlugins = override.plugins - const overrideFastCommandSeries = override.fastCommandSeriesOptions + const overrideCommandSeries = override.commandSeriesOptions return { ...base, ...override, - shadowSourceProject: mergeShadowSourceProject(base.shadowSourceProject, override.shadowSourceProject), + aindex: mergeAindex(base.aindex, override.aindex), plugins: [ // Array concatenation for plugins ...base.plugins, ...overridePlugins ?? [] ], - fastCommandSeriesOptions: mergeFastCommandSeriesOptions(base.fastCommandSeriesOptions, overrideFastCommandSeries) // Deep merge for fastCommandSeriesOptions + commandSeriesOptions: mergeCommandSeriesOptions(base.commandSeriesOptions, overrideCommandSeries) // Deep merge for commandSeriesOptions } } -function mergeShadowSourceProject( - base: ShadowSourceProjectConfig, - override?: ShadowSourceProjectConfig -): ShadowSourceProjectConfig { +function mergeAindex( + base: AindexConfig, + override?: AindexConfig +): AindexConfig { if (override == null) return base return { - name: override.name ?? base.name, - skill: {...base.skill, ...override.skill}, - fastCommand: {...base.fastCommand, ...override.fastCommand}, - subAgent: {...base.subAgent, ...override.subAgent}, - rule: {...base.rule, ...override.rule}, - globalMemory: {...base.globalMemory, ...override.globalMemory}, - workspaceMemory: {...base.workspaceMemory, ...override.workspaceMemory}, - project: {...base.project, ...override.project} + dir: override.dir ?? base.dir, + skills: {...base.skills, ...override.skills}, + commands: {...base.commands, ...override.commands}, + subAgents: {...base.subAgents, ...override.subAgents}, + rules: {...base.rules, ...override.rules}, + globalPrompt: {...base.globalPrompt, ...override.globalPrompt}, + workspacePrompt: {...base.workspacePrompt, ...override.workspacePrompt}, + app: {...base.app, ...override.app}, + ext: {...base.ext, ...override.ext}, + arch: {...base.arch, ...override.arch} } } -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} @@ -164,8 +168,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 +193,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, @@ -228,19 +241,19 @@ 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}, ...merged.globalMemory != null && {globalMemory: merged.globalMemory}, ...merged.aiAgentIgnoreConfigFiles != null && {aiAgentIgnoreConfigFiles: merged.aiAgentIgnoreConfigFiles}, - ...merged.shadowSourceProjectDir != null && {shadowSourceProjectDir: merged.shadowSourceProjectDir}, + ...merged.aindexDir != null && {aindexDir: merged.aindexDir}, ...merged.readmePrompts != null && {readmePrompts: merged.readmePrompts}, ...merged.globalGitIgnore != null && {globalGitIgnore: merged.globalGitIgnore}, ...merged.shadowGitExclude != null && {shadowGitExclude: merged.shadowGitExclude} } - if (merged.shadowSourceProjectDir != null) checkVersionControl(merged.shadowSourceProjectDir, logger) // Check version control status for shadow source project + if (merged.aindexDir != null) checkVersionControl(merged.aindexDir, logger) // Check version control status for aindex return {context, outputPlugins, userConfigOptions: mergedOptions} } 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..09e0e84e 100644 --- a/cli/src/config/schema.ts +++ b/cli/src/config/schema.ts @@ -6,7 +6,6 @@ */ import type { - AindexConfig, LogLevel, ModulePaths, Profile, @@ -33,9 +32,10 @@ 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'), + dir: z.string().default('aindex'), 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(z.union([ZModulePaths, z.string()])) /** * Zod schema for user profile. @@ -68,7 +68,7 @@ export const ZTnmscConfig = z.object({ aindex: ZAindexConfig, logLevel: z.enum(['trace', 'debug', 'info', 'warn', 'error']), profile: ZProfile -}) satisfies z.ZodType +}) /** * Validate a configuration object against the schema. @@ -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..1b3dab60 100644 --- a/cli/src/config/types.ts +++ b/cli/src/config/types.ts @@ -19,10 +19,11 @@ 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 + /** Aindex directory name (relative to workspaceDir), default: 'aindex' */ + readonly dir: string /** Skills module paths */ readonly skills: ModulePaths /** Commands module paths */ @@ -41,6 +42,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 | string } /** diff --git a/cli/src/constants.ts b/cli/src/constants.ts index 747cb185..53879ec7 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' +import type {UserConfigFile} from './plugins/plugin-shared' 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/libraries/config/src/lib.rs b/cli/src/core/config/mod.rs similarity index 88% rename from libraries/config/src/lib.rs rename to cli/src/core/config/mod.rs index 4cc882be..31f83f43 100644 --- a/libraries/config/src/lib.rs +++ b/cli/src/core/config/mod.rs @@ -1,4 +1,4 @@ -#![deny(clippy::all)] +#![deny(clippy::all)] //! Configuration loading, merging, and validation. //! @@ -28,7 +28,7 @@ pub const DEFAULT_GLOBAL_CONFIG_DIR: &str = ".aindex"; // Types — mirrors TS ConfigTypes.schema.ts // --------------------------------------------------------------------------- -/// A source/dist path pair. Both paths are relative to the shadow source project root. +/// A source/dist path pair. Both paths are relative to the aindex project root. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct DirPair { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -51,29 +51,37 @@ impl DirPair { } } -/// Shadow source project configuration. +/// Aindex configuration. /// All paths are relative to `/`. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct ShadowSourceProjectConfig { +pub struct AindexConfig { #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, + pub dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skills: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub skill: Option, + pub commands: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub fast_command: Option, + pub sub_agents: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub sub_agent: Option, + pub rules: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub rule: Option, + pub global_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub global_memory: Option, + pub workspace_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_memory: Option, + pub app: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub project: Option, + pub ext: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arch: Option, } +/// Shadow source project configuration (deprecated, use AindexConfig). +#[deprecated(since = "2026.10303.0", note = "Use AindexConfig instead")] +pub type ShadowSourceProjectConfig = AindexConfig; + /// Per-plugin fast command series override options. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -119,7 +127,7 @@ pub struct UserConfigFile { #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub shadow_source_project: Option, + pub aindex: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub log_level: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -188,38 +196,50 @@ pub fn get_global_config_path() -> PathBuf { // Merge logic // --------------------------------------------------------------------------- -fn merge_shadow_source_project( - a: &Option, - b: &Option, -) -> Option { +fn merge_aindex( + a: &Option, + b: &Option, +) -> Option { match (a, b) { (None, None) => None, (Some(v), None) => Some(v.clone()), (None, Some(v)) => Some(v.clone()), - (Some(base), Some(over)) => Some(ShadowSourceProjectConfig { - name: over.name.clone().or_else(|| base.name.clone()), - skill: DirPair::merge(&base.skill, &over.skill), - fast_command: DirPair::merge(&base.fast_command, &over.fast_command), - sub_agent: DirPair::merge(&base.sub_agent, &over.sub_agent), - rule: DirPair::merge(&base.rule, &over.rule), - global_memory: DirPair::merge(&base.global_memory, &over.global_memory), - workspace_memory: DirPair::merge(&base.workspace_memory, &over.workspace_memory), - project: DirPair::merge(&base.project, &over.project), + (Some(base), Some(over)) => Some(AindexConfig { + dir: over.dir.clone().or_else(|| base.dir.clone()), + skills: DirPair::merge(&base.skills, &over.skills), + commands: DirPair::merge(&base.commands, &over.commands), + sub_agents: DirPair::merge(&base.sub_agents, &over.sub_agents), + rules: DirPair::merge(&base.rules, &over.rules), + global_prompt: DirPair::merge(&base.global_prompt, &over.global_prompt), + workspace_prompt: DirPair::merge(&base.workspace_prompt, &over.workspace_prompt), + app: DirPair::merge(&base.app, &over.app), + ext: DirPair::merge(&base.ext, &over.ext), + arch: DirPair::merge(&base.arch, &over.arch), }), } } +/// Merge aindex configs (deprecated, use merge_aindex). +#[deprecated(since = "2026.10303.0", note = "Use merge_aindex instead")] +#[allow(dead_code)] +fn merge_shadow_source_project( + a: &Option, + b: &Option, +) -> Option { + merge_aindex(a, b) +} + /// Merge two configs. `over` fields take priority over `base`. pub fn merge_configs_pair(base: &UserConfigFile, over: &UserConfigFile) -> UserConfigFile { - let merged_shadow = merge_shadow_source_project( - &base.shadow_source_project, - &over.shadow_source_project, + let merged_aindex = merge_aindex( + &base.aindex, + &over.aindex, ); UserConfigFile { version: over.version.clone().or_else(|| base.version.clone()), workspace_dir: over.workspace_dir.clone().or_else(|| base.workspace_dir.clone()), - shadow_source_project: merged_shadow, + aindex: merged_aindex, log_level: over.log_level.clone().or_else(|| base.log_level.clone()), fast_command_series_options: over.fast_command_series_options.clone() .or_else(|| base.fast_command_series_options.clone()), @@ -570,10 +590,10 @@ pub fn ensure_config_link(local_path: &Path, global_path: &Path, logger: &Logger } } -/// Ensure the shadow source project directory has a `.tnmsc.json` symlink +/// Ensure the aindex project directory has a `.tnmsc.json` symlink /// pointing to the global config. -pub fn ensure_shadow_project_config_link(shadow_project_dir: &str, logger: &Logger) { - let resolved = resolve_tilde(shadow_project_dir); +pub fn ensure_aindex_config_link(aindex_dir: &str, logger: &Logger) { + let resolved = resolve_tilde(aindex_dir); if !resolved.exists() { return; } @@ -582,6 +602,14 @@ pub fn ensure_shadow_project_config_link(shadow_project_dir: &str, logger: &Logg ensure_config_link(&config_path, &global_path, logger); } +/// Ensure the shadow source project directory has a `.tnmsc.json` symlink +/// pointing to the global config (deprecated, use ensure_aindex_config_link). +#[deprecated(since = "2026.10303.0", note = "Use ensure_aindex_config_link instead")] +#[allow(dead_code)] +pub fn ensure_shadow_project_config_link(shadow_project_dir: &str, logger: &Logger) { + ensure_aindex_config_link(shadow_project_dir, logger); +} + /// Validate global config file strictly. /// /// - If config doesn't exist: create default config, log warn, continue @@ -732,7 +760,7 @@ mod tests { let config = UserConfigFile::default(); assert!(config.version.is_none()); assert!(config.workspace_dir.is_none()); - assert!(config.shadow_source_project.is_none()); + assert!(config.aindex.is_none()); assert!(config.log_level.is_none()); } @@ -748,23 +776,24 @@ mod tests { } #[test] - fn test_user_config_file_deserialize_with_shadow_project() { + fn test_user_config_file_deserialize_with_aindex() { let json = r#"{ - "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"} + "aindex": { + "skills": {"src": "src/skills", "dist": "dist/skills"}, + "commands": {"src": "src/commands", "dist": "dist/commands"}, + "subAgents": {"src": "src/agents", "dist": "dist/agents"}, + "rules": {"src": "src/rules", "dist": "dist/rules"}, + "globalPrompt": {"src": "app/global.cn.mdx", "dist": "dist/global.mdx"}, + "workspacePrompt": {"src": "app/workspace.cn.mdx", "dist": "dist/app/workspace.mdx"}, + "app": {"src": "app", "dist": "dist/app"}, + "ext": {"src": "ext", "dist": "dist/ext"}, + "arch": {"src": "arch", "dist": "dist/arch"} } }"#; let config: UserConfigFile = serde_json::from_str(json).unwrap(); - let sp = config.shadow_source_project.unwrap(); - assert_eq!(sp.name.as_deref(), Some("aindex")); - assert_eq!(sp.skill.as_ref().unwrap().src.as_deref(), Some("src/skills")); + let aindex = config.aindex.unwrap(); + assert_eq!(aindex.skills.as_ref().unwrap().src.as_deref(), Some("src/skills")); + assert_eq!(aindex.commands.as_ref().unwrap().src.as_deref(), Some("src/commands")); } #[test] @@ -822,8 +851,11 @@ mod tests { let global_config = UserConfigFile { workspace_dir: Some("~/global-workspace".into()), log_level: Some("info".into()), - shadow_source_project: Some(ShadowSourceProjectConfig { - name: Some("global-shadow".into()), + aindex: Some(AindexConfig { + skills: Some(DirPair { + src: Some("global/skills".into()), + dist: Some("global/dist/skills".into()), + }), ..Default::default() }), ..Default::default() @@ -834,17 +866,16 @@ mod tests { assert_eq!(result.workspace_dir.as_deref(), Some("~/cwd-workspace")); assert_eq!(result.log_level.as_deref(), Some("debug")); assert_eq!( - result.shadow_source_project.as_ref().and_then(|s| s.name.as_deref()), - Some("global-shadow") + result.aindex.as_ref().and_then(|s| s.skills.as_ref()).and_then(|p| p.src.as_deref()), + Some("global/skills") ); } #[test] - fn test_merge_shadow_source_project_deep() { + fn test_merge_aindex_deep() { let cwd_config = UserConfigFile { - shadow_source_project: Some(ShadowSourceProjectConfig { - name: Some("cwd-shadow".into()), - skill: Some(DirPair { + aindex: Some(AindexConfig { + skills: Some(DirPair { src: Some("custom/skills".into()), dist: Some("custom/dist/skills".into()), }), @@ -853,13 +884,12 @@ mod tests { ..Default::default() }; let global_config = UserConfigFile { - shadow_source_project: Some(ShadowSourceProjectConfig { - name: Some("global-shadow".into()), - skill: Some(DirPair { + aindex: Some(AindexConfig { + skills: Some(DirPair { src: Some("src/skills".into()), dist: Some("dist/skills".into()), }), - fast_command: Some(DirPair { + commands: Some(DirPair { src: Some("src/commands".into()), dist: Some("dist/commands".into()), }), @@ -869,10 +899,9 @@ mod tests { }; let result = merge_configs(&[cwd_config, global_config]); - let sp = result.shadow_source_project.unwrap(); - assert_eq!(sp.name.as_deref(), Some("cwd-shadow")); - assert_eq!(sp.skill.as_ref().unwrap().src.as_deref(), Some("custom/skills")); - assert_eq!(sp.fast_command.as_ref().unwrap().src.as_deref(), Some("src/commands")); + let aindex = result.aindex.unwrap(); + assert_eq!(aindex.skills.as_ref().unwrap().src.as_deref(), Some("custom/skills")); + assert_eq!(aindex.commands.as_ref().unwrap().src.as_deref(), Some("src/commands")); } #[test] diff --git a/libraries/config/src/series_filter.rs b/cli/src/core/config/series_filter.rs similarity index 100% rename from libraries/config/src/series_filter.rs rename to cli/src/core/config/series_filter.rs diff --git a/libraries/input-plugins/src/lib.rs b/cli/src/core/input_plugins.rs similarity index 100% rename from libraries/input-plugins/src/lib.rs rename to cli/src/core/input_plugins.rs diff --git a/cli/src/core/mod.rs b/cli/src/core/mod.rs new file mode 100644 index 00000000..7706c47f --- /dev/null +++ b/cli/src/core/mod.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod input_plugins; +pub mod plugin_shared; diff --git a/libraries/plugin-shared/src/lib.rs b/cli/src/core/plugin_shared.rs similarity index 100% rename from libraries/plugin-shared/src/lib.rs rename to cli/src/core/plugin_shared.rs diff --git a/cli/src/index.ts b/cli/src/index.ts index 09aac66b..e20b96da 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,14 +1,15 @@ import process from 'node:process' import {PluginPipeline} from '@/PluginPipeline' import userPluginConfigPromise from './plugin.config' +import {createLogger} from './plugins/plugin-shared' +export * from './Aindex' export * from './config' export * from './ConfigLoader' export * from './constants' export { default } from './plugin.config' -export * from './ShadowSourceProject' async function main(): Promise { const userPluginConfig = await userPluginConfigPromise @@ -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/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts b/cli/src/inputs/effect-md-cleanup.ts similarity index 94% rename from cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts rename to cli/src/inputs/effect-md-cleanup.ts index e2d40cd3..9270db34 100644 --- a/cli/src/plugins/plugin-input-md-cleanup-effect/MarkdownWhitespaceCleanupEffectInputPlugin.ts +++ b/cli/src/inputs/effect-md-cleanup.ts @@ -3,12 +3,9 @@ import type { InputEffectContext, InputEffectResult, InputPluginContext -} from '@truenine/plugin-shared' +} from '../plugins/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[] @@ -21,16 +18,16 @@ export class MarkdownWhitespaceCleanupEffectInputPlugin extends AbstractInputPlu } private async cleanupWhitespace(ctx: InputEffectContext): Promise { - const {fs, path, shadowProjectDir, dryRun, logger} = ctx + const {fs, path, aindexDir, dryRun, logger} = ctx const modifiedFiles: string[] = [] const skippedFiles: string[] = [] const errors: {path: string, error: Error}[] = [] const dirsToScan = [ - path.join(shadowProjectDir, 'src'), - path.join(shadowProjectDir, 'app'), - path.join(shadowProjectDir, 'dist') + path.join(aindexDir, 'src'), + path.join(aindexDir, 'app'), + path.join(aindexDir, 'dist') ] for (const dir of dirsToScan) { @@ -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 72% rename from cli/src/plugins/plugin-input-orphan-cleanup-effect/OrphanFileCleanupEffectInputPlugin.ts rename to cli/src/inputs/effect-orphan-cleanup.ts index b8a8f8c9..7b9510fa 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 type {CollectedInputContext, InputEffectContext, InputEffectResult, InputPluginContext} from '../plugins/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[] @@ -16,9 +13,9 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { } private async cleanupOrphanFiles(ctx: InputEffectContext): Promise { - const {fs, path, shadowProjectDir, dryRun, logger} = ctx + const {fs, path, aindexDir, dryRun, logger, userConfigOptions} = ctx - const distDir = path.join(shadowProjectDir, 'dist') + const distDir = path.join(aindexDir, 'dist') const deletedFiles: string[] = [] const deletedDirs: string[] = [] @@ -34,11 +31,19 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { } } + const aindexConfig = userConfigOptions.aindex + const srcPaths: Record = { + skills: aindexConfig?.skills?.src ?? 'skills', + commands: aindexConfig?.commands?.src ?? 'commands', + agents: aindexConfig?.subAgents?.src ?? 'subagents', + app: aindexConfig?.app?.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,12 +64,13 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { ctx: InputEffectContext, distDirPath: string, dirType: string, + srcPath: string, deletedFiles: string[], deletedDirs: string[], errors: {path: string, error: Error}[], dryRun: boolean ): void { - const {fs, path, shadowProjectDir, logger} = ctx + const {fs, path, aindexDir, logger} = ctx let entries: import('node:fs').Dirent[] try { @@ -80,11 +86,10 @@ 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, aindexDir) if (isOrphan) { if (dryRun) { @@ -110,72 +115,60 @@ export class OrphanFileCleanupEffectInputPlugin extends AbstractInputPlugin { ctx: InputEffectContext, distFilePath: string, dirType: string, - shadowProjectDir: string + srcPath: string, + aindexDir: string ): boolean { const {fs, path} = ctx const fileName = path.basename(distFilePath) const isMdxFile = fileName.endsWith('.mdx') - const distTypeDir = path.join(shadowProjectDir, 'dist', dirType) + const distTypeDir = path.join(aindexDir, 'dist', dirType) const relativeFromType = path.relative(distTypeDir, distFilePath) const relativeDir = path.dirname(relativeFromType) const baseName = fileName.replace(/\.mdx$/, '') if (isMdxFile) { - const possibleSrcPaths = this.getPossibleSourcePaths(path, shadowProjectDir, dirType, baseName, relativeDir) - + const possibleSrcPaths = this.getPossibleSourcePaths(path, aindexDir, 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(aindexDir, srcPath, relativeFromType)) return !possibleSrcPaths.some(srcPath => fs.existsSync(srcPath)) } private getPossibleSourcePaths( nodePath: typeof import('node:path'), - shadowProjectDir: string, + aindexDir: 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': { + const skillParts = relativeDir === '.' ? [baseName] : relativeDir.split(nodePath.sep) + const skillName = skillParts[0] ?? baseName + const remainingPath = relativeDir === '.' ? '' : relativeDir.slice(skillName.length + 1) + + if (remainingPath !== '') return [nodePath.join(aindexDir, srcPath, skillName, remainingPath, `${baseName}.cn.mdx`)] + return [ + nodePath.join(aindexDir, srcPath, skillName, 'SKILL.cn.mdx'), + nodePath.join(aindexDir, srcPath, skillName, 'skill.cn.mdx') + ] + } case 'commands': return relativeDir === '.' - ? [ - nodePath.join(shadowProjectDir, 'src', 'commands', `${baseName}.cn.mdx`) - ] - : [ - nodePath.join(shadowProjectDir, 'src', 'commands', relativeDir, `${baseName}.cn.mdx`) - ] + ? [nodePath.join(aindexDir, srcPath, `${baseName}.cn.mdx`)] + : [nodePath.join(aindexDir, srcPath, relativeDir, `${baseName}.cn.mdx`)] case 'agents': return relativeDir === '.' - ? [ - nodePath.join(shadowProjectDir, 'src', 'agents', `${baseName}.cn.mdx`) - ] - : [ - nodePath.join(shadowProjectDir, 'src', 'agents', relativeDir, `${baseName}.cn.mdx`) - ] + ? [nodePath.join(aindexDir, srcPath, `${baseName}.cn.mdx`)] + : [nodePath.join(aindexDir, srcPath, relativeDir, `${baseName}.cn.mdx`)] case 'app': return relativeDir === '.' - ? [ - nodePath.join(shadowProjectDir, 'app', `${baseName}.cn.mdx`) - ] - : [ - nodePath.join(shadowProjectDir, 'app', relativeDir, `${baseName}.cn.mdx`) - ] + ? [nodePath.join(aindexDir, srcPath, `${baseName}.cn.mdx`)] + : [nodePath.join(aindexDir, 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 94% rename from cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.ts rename to cli/src/inputs/effect-skill-sync.ts index c78a5a12..017b0408 100644 --- a/cli/src/plugins/plugin-input-skill-sync-effect/SkillNonSrcFileSyncEffectInputPlugin.ts +++ b/cli/src/inputs/effect-skill-sync.ts @@ -1,12 +1,9 @@ -import type {CollectedInputContext, InputEffectContext, InputEffectResult, InputPluginContext} from '@truenine/plugin-shared' - import type {Buffer} from 'node:buffer' + +import type {CollectedInputContext, InputEffectContext, InputEffectResult, InputPluginContext} from '../plugins/plugin-shared' 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[] @@ -20,10 +17,10 @@ export class SkillNonSrcFileSyncEffectInputPlugin extends AbstractInputPlugin { } private async syncNonSrcFiles(ctx: InputEffectContext): Promise { - const {fs, path, shadowProjectDir, dryRun, logger} = ctx + const {fs, path, aindexDir, dryRun, logger} = ctx - const srcSkillsDir = path.join(shadowProjectDir, 'src', 'skills') - const distSkillsDir = path.join(shadowProjectDir, 'dist', 'skills') + const srcSkillsDir = path.join(aindexDir, 'src', 'skills') + const distSkillsDir = path.join(aindexDir, 'dist', 'skills') const copiedFiles: string[] = [] const skippedFiles: string[] = [] diff --git a/cli/src/inputs/index.ts b/cli/src/inputs/index.ts new file mode 100644 index 00000000..22f38c17 --- /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 { + AindexInputPlugin +} from './input-aindex' +export { + CommandInputPlugin +} from './input-command' +export { + EditorConfigInputPlugin +} from './input-editorconfig' +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 { + 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..68c2499f --- /dev/null +++ b/cli/src/inputs/input-agentskills-types.ts @@ -0,0 +1,10 @@ +/** + * Types for SkillInputPlugin resource processing + */ + +import type {SkillChildDoc, SkillResource} from '../plugins/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..6bf480d3 --- /dev/null +++ b/cli/src/inputs/input-agentskills.ts @@ -0,0 +1,685 @@ +import type {Dirent} from 'node:fs' +import type { + CollectedInputContext, + ILogger, + InputPluginContext, + LocalizedPrompt, + LocalizedSkillPrompt, + McpServerConfig, + SkillChildDoc, + SkillMcpConfig, + SkillPrompt, + SkillResource, + SkillResourceEncoding, + SkillYAMLFrontMatter +} from '../plugins/plugin-shared' +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 '../plugins/plugin-shared' + +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', + '.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 distFilePath = nodePath.join(skillAbsoluteDir, 'skill.mdx') + let rawContent = content + let parsed: ReturnType> | undefined + + if (fs.existsSync(distFilePath)) { + try { + rawContent = fs.readFileSync(distFilePath, '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 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: finalDescription + } 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 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 + ) + + 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 {aindexDir} = this.resolveBasePaths(options) + + const srcSkillDir = this.resolveAindexPath(options.aindex.skills.src, aindexDir) + const distSkillDir = this.resolveAindexPath(options.aindex.skills.dist, aindexDir) + + 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 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, + skillDistDir, + 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-shadow-project/ShadowProjectInputPlugin.ts b/cli/src/inputs/input-aindex.ts similarity index 65% rename from cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.ts rename to cli/src/inputs/input-aindex.ts index ffcc7e2f..a3d0383f 100644 --- a/cli/src/plugins/plugin-input-shadow-project/ShadowProjectInputPlugin.ts +++ b/cli/src/inputs/input-aindex.ts @@ -1,26 +1,24 @@ -import type {CollectedInputContext, InputPluginContext, Project, Workspace} from '@truenine/plugin-shared' -import type {ProjectConfig} from '@truenine/plugin-shared/types' +import type {CollectedInputContext, InputPluginContext, Project, Workspace} from '../plugins/plugin-shared' +import type {ProjectConfig} from '../plugins/plugin-shared/types' import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind -} from '@truenine/plugin-shared' import {parse as parseJsonc} from 'jsonc-parser' +import {FilePathKind} from '../plugins/plugin-shared' -export class ShadowProjectInputPlugin extends AbstractInputPlugin { +export class AindexInputPlugin extends AbstractInputPlugin { constructor() { - super('ShadowProjectInputPlugin') + super('AindexInputPlugin') } private loadProjectConfig( projectName: string, - shadowProjectDir: string, + aindexDir: string, srcPath: string, fs: InputPluginContext['fs'], path: InputPluginContext['path'], logger: InputPluginContext['logger'] ): ProjectConfig | undefined { - const configPath = path.join(shadowProjectDir, srcPath, projectName, 'project.jsonc') + const configPath = path.join(aindexDir, srcPath, projectName, 'project.jsonc') if (!fs.existsSync(configPath)) return void 0 try { const raw = fs.readFileSync(configPath, 'utf8') @@ -39,25 +37,25 @@ export class ShadowProjectInputPlugin extends AbstractInputPlugin { collect(ctx: InputPluginContext): Partial { const {userConfigOptions: options, logger, fs, path} = ctx - const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(options) + const {workspaceDir, aindexDir} = this.resolveBasePaths(options) - const shadowProjectsDir = this.resolveShadowPath(options.shadowSourceProject.project.dist, shadowProjectDir) + const aindexProjectsDir = this.resolveAindexPath(options.aindex.app.dist, aindexDir) - const shadowSourceProjectName = path.basename(shadowProjectDir) + const aindexName = path.basename(aindexDir) - const shadowProjects: Project[] = [] + const aindexProjects: Project[] = [] - if (fs.existsSync(shadowProjectsDir) && fs.statSync(shadowProjectsDir).isDirectory()) { + if (fs.existsSync(aindexProjectsDir) && fs.statSync(aindexProjectsDir).isDirectory()) { try { - const entries = fs.readdirSync(shadowProjectsDir, {withFileTypes: true}) + const entries = fs.readdirSync(aindexProjectsDir, {withFileTypes: true}) for (const entry of entries) { if (entry.isDirectory()) { - const isTheShadowSourceProject = entry.name === shadowSourceProjectName - const projectConfig = this.loadProjectConfig(entry.name, shadowProjectDir, options.shadowSourceProject.project.src, fs, path, logger) + const isTheAindex = entry.name === aindexName + const projectConfig = this.loadProjectConfig(entry.name, aindexDir, options.aindex.app.src, fs, path, logger) - shadowProjects.push({ + aindexProjects.push({ name: entry.name, - ...isTheShadowSourceProject && {isPromptSourceProject: true}, + ...isTheAindex && {isPromptSourceProject: true}, ...projectConfig != null && {projectConfig}, dirFromWorkspacePath: { pathKind: FilePathKind.Relative, @@ -71,22 +69,22 @@ export class ShadowProjectInputPlugin extends AbstractInputPlugin { } } catch (e) { - logger.error('failed to scan shadow projects', {path: shadowProjectsDir, error: e}) + logger.error('failed to scan aindex projects', {path: aindexProjectsDir, error: e}) } } - if (shadowProjects.length === 0 && fs.existsSync(workspaceDir) && fs.statSync(workspaceDir).isDirectory()) { + if (aindexProjects.length === 0 && fs.existsSync(workspaceDir) && fs.statSync(workspaceDir).isDirectory()) { logger.debug('no projects in dist/app/, falling back to workspace scan', {workspaceDir}) try { const entries = fs.readdirSync(workspaceDir, {withFileTypes: true}) for (const entry of entries) { if (entry.isDirectory() && !entry.name.startsWith('.')) { - const isTheShadowSourceProject = entry.name === shadowSourceProjectName - const projectConfig = this.loadProjectConfig(entry.name, shadowProjectDir, options.shadowSourceProject.project.src, fs, path, logger) + const isTheAindex = entry.name === aindexName + const projectConfig = this.loadProjectConfig(entry.name, aindexDir, options.aindex.app.src, fs, path, logger) - shadowProjects.push({ + aindexProjects.push({ name: entry.name, - ...isTheShadowSourceProject && {isPromptSourceProject: true}, + ...isTheAindex && {isPromptSourceProject: true}, ...projectConfig != null && {projectConfig}, dirFromWorkspacePath: { pathKind: FilePathKind.Relative, @@ -110,7 +108,7 @@ export class ShadowProjectInputPlugin extends AbstractInputPlugin { path: workspaceDir, getDirectoryName: () => path.basename(workspaceDir) }, - projects: shadowProjects + projects: aindexProjects } return {workspace} diff --git a/cli/src/inputs/input-command.ts b/cli/src/inputs/input-command.ts new file mode 100644 index 00000000..19f31428 --- /dev/null +++ b/cli/src/inputs/input-command.ts @@ -0,0 +1,157 @@ +import type { + CollectedInputContext, + CommandPrompt, + InputPluginContext, + Locale, + LocalizedCommandPrompt, + PluginOptions, + ResolvedBasePaths +} from '../plugins/plugin-shared' +import { + AbstractInputPlugin, + createLocalizedPromptReader +} from '@truenine/plugin-input-shared' +import { + FilePathKind, + PromptKind +} from '../plugins/plugin-shared' + +export interface CommandPrefixInfo { + readonly commandPrefix?: string + readonly commandName: string +} + +export class CommandInputPlugin extends AbstractInputPlugin { + constructor() { + super('CommandInputPlugin') + } + + private getDistDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + return this.resolveAindexPath(options.aindex.commands.dist, resolvedPaths.aindexDir) + } + + private createCommandPrompt( + content: string, + _locale: Locale, + name: string, + _srcDir: string, + distDir: string, + ctx: InputPluginContext, + _rawContent?: string + ): CommandPrompt { + const {path} = ctx + + const normalizedName = name.replaceAll('\\', '/') // Normalize Windows backslashes to forward slashes + const slashIndex = normalizedName.indexOf('/') + const parentDirName = slashIndex !== -1 ? normalizedName.slice(0, slashIndex) : void 0 + const fileName = slashIndex !== -1 ? normalizedName.slice(slashIndex + 1) : normalizedName + + const prefixInfo = this.extractPrefixInfo(fileName, parentDirName) + + const filePath = path.join(distDir, `${name}.mdx`) + const entryName = `${name}.mdx` + + return { + type: PromptKind.Command, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: { + pathKind: FilePathKind.Relative, + path: entryName, + basePath: distDir, + getDirectoryName: () => entryName.replace(/\.mdx$/, ''), + getAbsolutePath: () => filePath + }, + ...prefixInfo.commandPrefix != null && {commandPrefix: prefixInfo.commandPrefix}, + commandName: prefixInfo.commandName + } as CommandPrompt + } + + extractPrefixInfo(fileName: string, parentDirName?: string): CommandPrefixInfo { + const baseName = fileName.replace(/\.mdx$/, '') + + if (parentDirName != null) { + return { + commandPrefix: parentDirName, + commandName: baseName + } + } + + const underscoreIndex = baseName.indexOf('_') + + if (underscoreIndex === -1) return {commandName: baseName} + + return { + commandPrefix: baseName.slice(0, Math.max(0, underscoreIndex)), + commandName: baseName.slice(Math.max(0, underscoreIndex + 1)) + } + } + + override async collect(ctx: InputPluginContext): Promise> { + const {userConfigOptions: options, logger, path, fs, globalScope} = ctx + const resolvedPaths = this.resolveBasePaths(options) + + const srcDir = this.resolveAindexPath(options.aindex.commands.src, resolvedPaths.aindexDir) + const distDir = this.getDistDir(options, resolvedPaths) + + logger.debug('CommandInputPlugin collecting', { + srcDir, + distDir, + aindexDir: resolvedPaths.aindexDir + }) + + const reader = createLocalizedPromptReader(fs, path, logger, globalScope) + + const {prompts: localizedCommands, errors} = await reader.readFlatFiles( + srcDir, + distDir, + { + kind: PromptKind.Command, + localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, + isDirectoryStructure: false, + createPrompt: async (content, locale, name) => this.createCommandPrompt( + content, + locale, + name, + srcDir, + distDir, + ctx + ) + } + ) + + 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: CommandPrompt[] = [] + for (const localized of localizedCommands) { + const prompt = localized.dist?.prompt ?? localized.src.default.prompt + if (prompt) legacyCommands.push(prompt) + } + + 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 { + prompts: { + skills: [], + commands: localizedCommands, + subAgents: [], + rules: [], + readme: [] + }, + promptIndex, + commands: legacyCommands + } + } +} diff --git a/cli/src/plugins/plugin-input-editorconfig/EditorConfigInputPlugin.ts b/cli/src/inputs/input-editorconfig.ts similarity index 79% rename from cli/src/plugins/plugin-input-editorconfig/EditorConfigInputPlugin.ts rename to cli/src/inputs/input-editorconfig.ts index 3c74eba8..ce8f6517 100644 --- a/cli/src/plugins/plugin-input-editorconfig/EditorConfigInputPlugin.ts +++ b/cli/src/inputs/input-editorconfig.ts @@ -1,15 +1,15 @@ -import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '@truenine/plugin-shared' +import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '../plugins/plugin-shared' import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {FilePathKind, IDEKind} from '@truenine/plugin-shared' +import {FilePathKind, IDEKind} from '../plugins/plugin-shared' function readIdeConfigFile( type: T, relativePath: string, - shadowProjectDir: string, + aindexDir: string, fs: typeof import('node:fs'), path: typeof import('node:path') ): ProjectIDEConfigFile | undefined { - const absPath = path.join(shadowProjectDir, relativePath) + const absPath = path.join(aindexDir, relativePath) if (!(fs.existsSync(absPath) && fs.statSync(absPath).isFile())) return void 0 const content = fs.readFileSync(absPath, 'utf8') @@ -33,10 +33,10 @@ export class EditorConfigInputPlugin extends AbstractInputPlugin { collect(ctx: InputPluginContext): Partial { const {userConfigOptions, fs, path} = ctx - const {shadowProjectDir} = this.resolveBasePaths(userConfigOptions) + const {aindexDir} = this.resolveBasePaths(userConfigOptions) const editorConfigFiles: ProjectIDEConfigFile[] = [] - const file = readIdeConfigFile(IDEKind.EditorConfig, '.editorconfig', shadowProjectDir, fs, path) + const file = readIdeConfigFile(IDEKind.EditorConfig, '.editorconfig', aindexDir, fs, path) if (file != null) editorConfigFiles.push(file) return {editorConfigFiles} diff --git a/cli/src/inputs/input-git-exclude.ts b/cli/src/inputs/input-git-exclude.ts new file mode 100644 index 00000000..6b7af6c8 --- /dev/null +++ b/cli/src/inputs/input-git-exclude.ts @@ -0,0 +1,29 @@ +import type {CollectedInputContext, InputPluginContext} from '../plugins/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 {aindexDir} = this.resolveBasePaths(ctx.userConfigOptions) + const filePath = path.join(aindexDir, '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..46708045 --- /dev/null +++ b/cli/src/inputs/input-gitignore.ts @@ -0,0 +1,29 @@ +import type {CollectedInputContext, InputPluginContext} from '../plugins/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 {aindexDir} = this.resolveBasePaths(ctx.userConfigOptions) + const filePath = path.join(aindexDir, '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 85% rename from cli/src/plugins/plugin-input-global-memory/GlobalMemoryInputPlugin.ts rename to cli/src/inputs/input-global-memory.ts index e4873da7..b0ae48fc 100644 --- a/cli/src/plugins/plugin-input-global-memory/GlobalMemoryInputPlugin.ts +++ b/cli/src/inputs/input-global-memory.ts @@ -1,4 +1,4 @@ -import type {CollectedInputContext, InputPluginContext} from '@truenine/plugin-shared' +import type {CollectedInputContext, InputPluginContext} from '../plugins/plugin-shared' import * as os from 'node:os' import process from 'node:process' @@ -11,7 +11,7 @@ import { FilePathKind, GlobalConfigDirectoryType, PromptKind -} from '@truenine/plugin-shared' +} from '../plugins/plugin-shared' export class GlobalMemoryInputPlugin extends AbstractInputPlugin { constructor() { @@ -20,9 +20,9 @@ export class GlobalMemoryInputPlugin extends AbstractInputPlugin { async collect(ctx: InputPluginContext): Promise> { const {userConfigOptions: options, fs, path, globalScope} = ctx - const {shadowProjectDir} = this.resolveBasePaths(options) + const {aindexDir} = this.resolveBasePaths(options) - const globalMemoryFile = this.resolveShadowPath(options.shadowSourceProject.globalMemory.dist, shadowProjectDir) + const globalMemoryFile = this.resolveAindexPath(options.aindex.globalPrompt.dist, aindexDir) if (!fs.existsSync(globalMemoryFile)) { this.log.warn({action: 'collect', reason: 'fileNotFound', path: globalMemoryFile}) @@ -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 81% rename from cli/src/plugins/plugin-input-jetbrains-config/JetBrainsConfigInputPlugin.ts rename to cli/src/inputs/input-jetbrains-config.ts index 2b3e25a0..38d601f1 100644 --- a/cli/src/plugins/plugin-input-jetbrains-config/JetBrainsConfigInputPlugin.ts +++ b/cli/src/inputs/input-jetbrains-config.ts @@ -1,15 +1,15 @@ -import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '@truenine/plugin-shared' +import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '../plugins/plugin-shared' import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {FilePathKind, IDEKind} from '@truenine/plugin-shared' +import {FilePathKind, IDEKind} from '../plugins/plugin-shared' function readIdeConfigFile( type: T, relativePath: string, - shadowProjectDir: string, + aindexDir: string, fs: typeof import('node:fs'), path: typeof import('node:path') ): ProjectIDEConfigFile | undefined { - const absPath = path.join(shadowProjectDir, relativePath) + const absPath = path.join(aindexDir, relativePath) if (!(fs.existsSync(absPath) && fs.statSync(absPath).isFile())) return void 0 const content = fs.readFileSync(absPath, 'utf8') @@ -33,7 +33,7 @@ export class JetBrainsConfigInputPlugin extends AbstractInputPlugin { collect(ctx: InputPluginContext): Partial { const {userConfigOptions, fs, path} = ctx - const {shadowProjectDir} = this.resolveBasePaths(userConfigOptions) + const {aindexDir} = this.resolveBasePaths(userConfigOptions) const files = [ '.idea/codeStyles/Project.xml', @@ -43,7 +43,7 @@ export class JetBrainsConfigInputPlugin extends AbstractInputPlugin { const jetbrainsConfigFiles: ProjectIDEConfigFile[] = [] for (const relativePath of files) { - const file = readIdeConfigFile(IDEKind.IntellijIDEA, relativePath, shadowProjectDir, fs, path) + const file = readIdeConfigFile(IDEKind.IntellijIDEA, relativePath, aindexDir, fs, path) if (file != null) jetbrainsConfigFiles.push(file) } diff --git a/cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts b/cli/src/inputs/input-project-prompt.ts similarity index 94% rename from cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts rename to cli/src/inputs/input-project-prompt.ts index 5ac29d81..79167c60 100644 --- a/cli/src/plugins/plugin-input-project-prompt/ProjectPromptInputPlugin.ts +++ b/cli/src/inputs/input-project-prompt.ts @@ -4,7 +4,7 @@ import type { ProjectChildrenMemoryPrompt, ProjectRootMemoryPrompt, YAMLFrontMatter -} from '@truenine/plugin-shared' +} from '../plugins/plugin-shared' import process from 'node:process' @@ -15,28 +15,21 @@ import {AbstractInputPlugin} from '@truenine/plugin-input-shared' import { FilePathKind, PromptKind -} from '@truenine/plugin-shared' +} from '../plugins/plugin-shared' -/** - * Project memory prompt file name - */ const PROJECT_MEMORY_FILE = 'agt.mdx' - -/** - * Directories to skip during recursive scanning - */ const SCAN_SKIP_DIRECTORIES: readonly string[] = ['node_modules', '.git'] as const export class ProjectPromptInputPlugin extends AbstractInputPlugin { constructor() { - super('ProjectPromptInputPlugin', ['ShadowProjectInputPlugin']) + super('ProjectPromptInputPlugin', ['AindexInputPlugin']) } async collect(ctx: InputPluginContext): Promise> { const {dependencyContext, fs, userConfigOptions: options, path, globalScope} = ctx - const {shadowProjectDir} = this.resolveBasePaths(options) + const {aindexDir} = this.resolveBasePaths(options) - const shadowProjectsDir = this.resolveShadowPath(options.shadowSourceProject.project.dist, shadowProjectDir) + const shadowProjectsDir = this.resolveAindexPath(options.aindex.app.dist, aindexDir) const dependencyWorkspace = dependencyContext.workspace if (dependencyWorkspace == null) { diff --git a/cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.ts b/cli/src/inputs/input-readme.ts similarity index 77% rename from cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.ts rename to cli/src/inputs/input-readme.ts index 28460926..b5c50d64 100644 --- a/cli/src/plugins/plugin-input-readme/ReadmeMdInputPlugin.ts +++ b/cli/src/inputs/input-readme.ts @@ -1,52 +1,40 @@ -import type {CollectedInputContext, InputPluginContext, ReadmeFileKind, ReadmePrompt, RelativePath} from '@truenine/plugin-shared' +import type {CollectedInputContext, InputPluginContext, ReadmeFileKind, ReadmePrompt, RelativePath} from '../plugins/plugin-shared' import process from 'node:process' import {mdxToMd} from '@truenine/md-compiler' import {ScopeError} from '@truenine/md-compiler/errors' import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {FilePathKind, PromptKind, README_FILE_KIND_MAP} from '@truenine/plugin-shared' +import {FilePathKind, PromptKind, README_FILE_KIND_MAP} from '../plugins/plugin-shared' const ALL_FILE_KINDS = Object.entries(README_FILE_KIND_MAP) as [ReadmeFileKind, {src: string, out: string}][] -/** - * Input plugin for collecting readme-family mdx files from shadow project directories. - * Scans dist/app/ directories for rdm.mdx, coc.mdx, security.mdx files - * and collects them as ReadmePrompt objects. - * - * Supports both root files (in project root) and child files (in subdirectories). - * - * Source → Output mapping: - * - rdm.mdx → README.md - * - coc.mdx → CODE_OF_CONDUCT.md - * - security.mdx → SECURITY.md - */ export class ReadmeMdInputPlugin extends AbstractInputPlugin { constructor() { - super('ReadmeMdInputPlugin', ['ShadowProjectInputPlugin']) + super('ReadmeMdInputPlugin', ['AindexInputPlugin']) } async collect(ctx: InputPluginContext): Promise> { const {userConfigOptions: options, logger, fs, path, globalScope} = ctx - const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(options) + const {workspaceDir, aindexDir} = this.resolveBasePaths(options) - const shadowProjectsDir = this.resolveShadowPath(options.shadowSourceProject.project.dist, shadowProjectDir) + const aindexProjectsDir = this.resolveAindexPath(options.aindex.app.dist, aindexDir) const readmePrompts: ReadmePrompt[] = [] - if (!fs.existsSync(shadowProjectsDir) || !fs.statSync(shadowProjectsDir).isDirectory()) { - logger.debug('shadow projects directory does not exist', {path: shadowProjectsDir}) + if (!fs.existsSync(aindexProjectsDir) || !fs.statSync(aindexProjectsDir).isDirectory()) { + logger.debug('aindex projects directory does not exist', {path: aindexProjectsDir}) return {readmePrompts} } try { - const projectEntries = fs.readdirSync(shadowProjectsDir, {withFileTypes: true}) + const projectEntries = fs.readdirSync(aindexProjectsDir, {withFileTypes: true}) for (const projectEntry of projectEntries) { if (!projectEntry.isDirectory()) continue const projectName = projectEntry.name - const projectDir = path.join(shadowProjectsDir, projectName) + const projectDir = path.join(aindexProjectsDir, projectName) await this.collectReadmeFiles( ctx, @@ -60,7 +48,7 @@ export class ReadmeMdInputPlugin extends AbstractInputPlugin { } } catch (e) { - logger.error('failed to scan shadow projects', {path: shadowProjectsDir, error: e}) + logger.error('failed to scan aindex projects', {path: aindexProjectsDir, error: e}) } return {readmePrompts} diff --git a/cli/src/inputs/input-rule.ts b/cli/src/inputs/input-rule.ts new file mode 100644 index 00000000..ce51e374 --- /dev/null +++ b/cli/src/inputs/input-rule.ts @@ -0,0 +1,228 @@ +import type { + CollectedInputContext, + InputPluginContext, + LocalizedRulePrompt, + PluginOptions, + ResolvedBasePaths, + RulePrompt, + RuleScope +} from '../plugins/plugin-shared' +import {mdxToMd} from '@truenine/md-compiler' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import { + AbstractInputPlugin, + createLocalizedPromptReader +} from '@truenine/plugin-input-shared' +import { + FilePathKind, + PromptKind +} from '../plugins/plugin-shared' + +export class RuleInputPlugin extends AbstractInputPlugin { + constructor() { + super('RuleInputPlugin') + } + + private getDistDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + return this.resolveAindexPath(options.aindex.rules.dist, resolvedPaths.aindexDir) + } + + private getSrcDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + return this.resolveAindexPath(options.aindex.rules.src, resolvedPaths.aindexDir) + } + + override async collect(ctx: InputPluginContext): Promise> { + const {userConfigOptions: options, logger, path, fs, globalScope} = ctx + const resolvedPaths = this.resolveBasePaths(options) + + const srcDir = this.getSrcDir(options, resolvedPaths) + const distDir = this.getDistDir(options, resolvedPaths) + + const reader = createLocalizedPromptReader(fs, path, logger, globalScope) + + const {prompts: localizedRulesFromSrc, errors} = await reader.readFlatFiles( + srcDir, + distDir, + { + kind: PromptKind.Rule, + localeExtensions: {zh: '.cn.mdx', en: '.mdx'}, + isDirectoryStructure: false, + createPrompt: async (content, _locale, name) => { + const distFilePath = path.join(distDir, `${name}.mdx`) + let globs: readonly string[] = [] + let scope: RuleScope = 'project' + let seriName: string | undefined, + yamlFrontMatter: Record | undefined, + rawFrontMatter: string | undefined + + try { + const rawContent = fs.readFileSync(distFilePath, '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 + } + } + ) + + const legacyRules: RulePrompt[] = [] + const localizedRules: LocalizedRulePrompt[] = [...localizedRulesFromSrc] + + if (fs.existsSync(distDir)) { + try { + const entries = fs.readdirSync(distDir, {withFileTypes: true}) + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + const seriesName = entry.name + const seriesDir = path.join(distDir, seriesName) + + const alreadyProcessed = localizedRulesFromSrc.some(r => r.name.startsWith(`${seriesName}/`)) + if (alreadyProcessed) continue + + try { + 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 + + try { + const rawContent = fs.readFileSync(distFilePath, 'utf8') + const parsed = parseMarkdown(rawContent) + + const content = globalScope != null ? await mdxToMd(rawContent, {globalScope, basePath: seriesDir}) : parsed.contentWithoutFrontMatter ?? rawContent + + 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 (error) { + logger.warn('Failed to scan series directory', {path: seriesDir, error}) + } + } + } catch (error) { + logger.warn('Failed to scan dist directory', {path: distDir, error}) + } + } + + for (const error of errors) logger.warn('Failed to read rule from src', {path: error.path, phase: error.phase, error: error.error}) + + const promptIndex = new Map() + for (const rule of localizedRules) promptIndex.set(rule.name, rule) + + 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-ignore/AIAgentIgnoreInputPlugin.ts b/cli/src/inputs/input-shared-ignore.ts similarity index 62% rename from cli/src/plugins/plugin-input-shared-ignore/AIAgentIgnoreInputPlugin.ts rename to cli/src/inputs/input-shared-ignore.ts index 2cfb14b4..62764844 100644 --- a/cli/src/plugins/plugin-input-shared-ignore/AIAgentIgnoreInputPlugin.ts +++ b/cli/src/inputs/input-shared-ignore.ts @@ -1,33 +1,28 @@ -import type {AIAgentIgnoreConfigFile, CollectedInputContext, InputPluginContext} from '@truenine/plugin-shared' +import type {AIAgentIgnoreConfigFile, CollectedInputContext, InputPluginContext} from '../plugins/plugin-shared' import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {SHADOW_SOURCE_FILE_NAMES} from '@truenine/plugin-shared' +import {AINDEX_FILE_NAMES} from '../plugins/plugin-shared' const IGNORE_FILE_NAMES: readonly string[] = [ - SHADOW_SOURCE_FILE_NAMES.QODER_IGNORE, - SHADOW_SOURCE_FILE_NAMES.CURSOR_IGNORE, - SHADOW_SOURCE_FILE_NAMES.WARP_INDEX_IGNORE, - SHADOW_SOURCE_FILE_NAMES.AI_IGNORE, - SHADOW_SOURCE_FILE_NAMES.CODEIUM_IGNORE, + AINDEX_FILE_NAMES.QODER_IGNORE, + AINDEX_FILE_NAMES.CURSOR_IGNORE, + AINDEX_FILE_NAMES.WARP_INDEX_IGNORE, + AINDEX_FILE_NAMES.AI_IGNORE, + AINDEX_FILE_NAMES.CODEIUM_IGNORE, '.kiroignore', '.traeignore' ] as const -/** - * Input plugin that reads AI agent ignore files from shadow source project root. - * Reads files like .kiroignore, .aiignore, .cursorignore, etc. - * and populates aiAgentIgnoreConfigFiles in CollectedInputContext. - */ export class AIAgentIgnoreInputPlugin extends AbstractInputPlugin { constructor() { super('AIAgentIgnoreInputPlugin') } collect(ctx: InputPluginContext): Partial { - const {shadowProjectDir} = this.resolveBasePaths(ctx.userConfigOptions) + const {aindexDir} = this.resolveBasePaths(ctx.userConfigOptions) const results: AIAgentIgnoreConfigFile[] = [] for (const fileName of IGNORE_FILE_NAMES) { - const filePath = ctx.path.join(shadowProjectDir, fileName) + const filePath = ctx.path.join(aindexDir, fileName) if (!ctx.fs.existsSync(filePath)) { this.log.debug({action: 'collect', message: 'Ignore file not found', path: filePath}) continue diff --git a/cli/src/inputs/input-subagent.ts b/cli/src/inputs/input-subagent.ts new file mode 100644 index 00000000..dac72a24 --- /dev/null +++ b/cli/src/inputs/input-subagent.ts @@ -0,0 +1,156 @@ +import type { + CollectedInputContext, + InputPluginContext, + Locale, + LocalizedSubAgentPrompt, + PluginOptions, + ResolvedBasePaths, + SubAgentPrompt +} from '../plugins/plugin-shared' +import { + AbstractInputPlugin, + createLocalizedPromptReader +} from '@truenine/plugin-input-shared' +import { + FilePathKind, + PromptKind +} from '../plugins/plugin-shared' + +export interface AgentPrefixInfo { + readonly agentPrefix?: string + readonly agentName: string +} + +export class SubAgentInputPlugin extends AbstractInputPlugin { + constructor() { + super('SubAgentInputPlugin') + } + + private getDistDir(options: Required, resolvedPaths: ResolvedBasePaths): string { + return this.resolveAindexPath(options.aindex.subAgents.dist, resolvedPaths.aindexDir) + } + + private createSubAgentPrompt( + content: string, + _locale: Locale, + name: string, + _srcDir: string, + distDir: string, + ctx: InputPluginContext + ): SubAgentPrompt { + const {path} = ctx + + const normalizedName = name.replaceAll('\\', '/') // Normalize Windows backslashes to forward slashes + const slashIndex = normalizedName.indexOf('/') + const parentDirName = slashIndex !== -1 ? normalizedName.slice(0, slashIndex) : void 0 + const fileName = slashIndex !== -1 ? normalizedName.slice(slashIndex + 1) : normalizedName + + const prefixInfo = this.extractPrefixInfo(fileName, parentDirName) + + const filePath = path.join(distDir, `${name}.mdx`) + const entryName = `${name}.mdx` + + return { + type: PromptKind.SubAgent, + content, + length: content.length, + filePathKind: FilePathKind.Relative, + dir: { + pathKind: FilePathKind.Relative, + path: entryName, + basePath: distDir, + getDirectoryName: () => entryName.replace(/\.mdx$/, ''), + getAbsolutePath: () => filePath + }, + ...prefixInfo.agentPrefix != null && {agentPrefix: prefixInfo.agentPrefix}, + agentName: prefixInfo.agentName + } as SubAgentPrompt + } + + extractPrefixInfo(fileName: string, parentDirName?: string): AgentPrefixInfo { + const baseName = fileName.replace(/\.mdx$/, '') + + if (parentDirName != null) { + return { + agentPrefix: parentDirName, + agentName: baseName + } + } + + const underscoreIndex = baseName.indexOf('_') + + if (underscoreIndex === -1) return {agentName: baseName} + + return { + agentPrefix: baseName.slice(0, Math.max(0, underscoreIndex)), + agentName: baseName.slice(Math.max(0, underscoreIndex + 1)) + } + } + + override async collect(ctx: InputPluginContext): Promise> { + const {userConfigOptions: options, logger, path, fs, globalScope} = ctx + const resolvedPaths = this.resolveBasePaths(options) + + const srcDir = this.resolveAindexPath(options.aindex.subAgents.src, resolvedPaths.aindexDir) + const distDir = this.getDistDir(options, resolvedPaths) + + logger.debug('SubAgentInputPlugin collecting', { + srcDir, + distDir, + aindexDir: resolvedPaths.aindexDir + }) + + const reader = createLocalizedPromptReader(fs, path, logger, globalScope) + + const {prompts: localizedSubAgents, errors} = await reader.readFlatFiles( + srcDir, + distDir, + { + kind: PromptKind.SubAgent, + localeExtensions: {zh: '.md', en: '.mdx'}, + isDirectoryStructure: false, + createPrompt: async (content, locale, name) => this.createSubAgentPrompt( + content, + locale, + name, + srcDir, + distDir, + ctx + ) + } + ) + + 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[] = [] + for (const localized of localizedSubAgents) { + const prompt = localized.dist?.prompt ?? localized.src.default.prompt + 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) + + return { + prompts: { + skills: [], + commands: [], + subAgents: localizedSubAgents, + rules: [], + readme: [] + }, + promptIndex, + subAgents: legacySubAgents + } + } +} diff --git a/cli/src/plugins/plugin-input-vscode-config/VSCodeConfigInputPlugin.ts b/cli/src/inputs/input-vscode-config.ts similarity index 81% rename from cli/src/plugins/plugin-input-vscode-config/VSCodeConfigInputPlugin.ts rename to cli/src/inputs/input-vscode-config.ts index c975e526..86a4282c 100644 --- a/cli/src/plugins/plugin-input-vscode-config/VSCodeConfigInputPlugin.ts +++ b/cli/src/inputs/input-vscode-config.ts @@ -1,15 +1,15 @@ -import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '@truenine/plugin-shared' +import type {CollectedInputContext, InputPluginContext, ProjectIDEConfigFile} from '../plugins/plugin-shared' import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import {FilePathKind, IDEKind} from '@truenine/plugin-shared' +import {FilePathKind, IDEKind} from '../plugins/plugin-shared' function readIdeConfigFile( type: T, relativePath: string, - shadowProjectDir: string, + aindexDir: string, fs: typeof import('node:fs'), path: typeof import('node:path') ): ProjectIDEConfigFile | undefined { - const absPath = path.join(shadowProjectDir, relativePath) + const absPath = path.join(aindexDir, relativePath) if (!(fs.existsSync(absPath) && fs.statSync(absPath).isFile())) return void 0 const content = fs.readFileSync(absPath, 'utf8') @@ -33,13 +33,13 @@ export class VSCodeConfigInputPlugin extends AbstractInputPlugin { collect(ctx: InputPluginContext): Partial { const {userConfigOptions, fs, path} = ctx - const {shadowProjectDir} = this.resolveBasePaths(userConfigOptions) + const {aindexDir} = this.resolveBasePaths(userConfigOptions) const files = ['.vscode/settings.json', '.vscode/extensions.json'] const vscodeConfigFiles: ProjectIDEConfigFile[] = [] for (const relativePath of files) { - const file = readIdeConfigFile(IDEKind.VSCode, relativePath, shadowProjectDir, fs, path) + const file = readIdeConfigFile(IDEKind.VSCode, relativePath, aindexDir, fs, path) if (file != null) vscodeConfigFiles.push(file) } diff --git a/cli/src/plugins/plugin-input-workspace/WorkspaceInputPlugin.ts b/cli/src/inputs/input-workspace.ts similarity index 75% rename from cli/src/plugins/plugin-input-workspace/WorkspaceInputPlugin.ts rename to cli/src/inputs/input-workspace.ts index 2c46ad25..32a0f5a2 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 type {CollectedInputContext, InputPluginContext, Workspace} from '../plugins/plugin-shared' import * as path from 'node:path' import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind -} from '@truenine/plugin-shared' +import {FilePathKind} from '../plugins/plugin-shared' export class WorkspaceInputPlugin extends AbstractInputPlugin { constructor() { @@ -12,7 +10,7 @@ export class WorkspaceInputPlugin extends AbstractInputPlugin { collect(ctx: InputPluginContext): Partial { const {userConfigOptions: options} = ctx - const {workspaceDir, shadowProjectDir} = this.resolveBasePaths(options) + const {workspaceDir, aindexDir} = this.resolveBasePaths(options) const workspace: Workspace = { directory: { @@ -25,7 +23,7 @@ export class WorkspaceInputPlugin extends AbstractInputPlugin { return { workspace, - shadowSourceProjectDir: shadowProjectDir + aindexDir } } } diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 3ef5b59a..7b2495e7 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -1,10 +1,11 @@ //! 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; pub mod commands; +pub mod core; use std::path::Path; @@ -41,14 +42,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")] @@ -68,43 +61,16 @@ pub fn version() -> &'static str { } /// Load and merge configuration from the given working directory. -pub fn load_config(cwd: &Path) -> Result { - Ok(tnmsc_config::ConfigLoader::with_defaults().load(cwd)) +pub fn load_config(cwd: &Path) -> Result { + Ok(core::config::ConfigLoader::with_defaults().load(cwd)) } /// Return the merged configuration as a pretty-printed JSON string. pub fn config_show(cwd: &Path) -> Result { - let result = tnmsc_config::ConfigLoader::with_defaults().load(cwd); + let result = core::config::ConfigLoader::with_defaults().load(cwd); 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 +168,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. @@ -657,4 +588,4 @@ mod cargo_config_tests { "root Cargo.toml [workspace.dependencies] should contain `tnmsc = {{ path = \"cli\" }}`" ); } -} \ No newline at end of file +} 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(), diff --git a/cli/src/pipeline/CliArgumentParser.ts b/cli/src/pipeline/CliArgumentParser.ts new file mode 100644 index 00000000..7476c347 --- /dev/null +++ b/cli/src/pipeline/CliArgumentParser.ts @@ -0,0 +1,265 @@ +/** + * 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 {createDefaultCommandRegistry} from '@/commands/CommandRegistryFactory' + +/** + * 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 +} + +/** + * Singleton instance of the command registry + * Lazy-loaded to ensure factories are only created when needed + */ +let commandRegistry: ReturnType | undefined + +/** + * Get or create the command registry singleton + */ +function getCommandRegistry(): ReturnType { + commandRegistry ??= createDefaultCommandRegistry() + return commandRegistry +} + +/** + * Reset the command registry singleton (useful for testing) + */ +export function resetCommandRegistry(): void { + commandRegistry = void 0 +} + +/** + * 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/pipeline/ContextMerger.ts b/cli/src/pipeline/ContextMerger.ts new file mode 100644 index 00000000..cc053aed --- /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 '../plugins/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 + }, + commands: { + strategy: 'concat', + getter: ctx => ctx.commands + }, + 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 + }, + aindexDir: { + strategy: 'override', + getter: ctx => ctx.aindexDir + }, + 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..60fa25ce --- /dev/null +++ b/cli/src/pipeline/PluginDependencyResolver.ts @@ -0,0 +1,154 @@ +/** + * Plugin Dependency Resolver Module + * Handles dependency graph building, validation, and topological sorting + */ + +import type {Plugin, PluginKind} from '../plugins/plugin-shared' +import {CircularDependencyError, MissingDependencyError} from '../plugins/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 + 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)! + 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 = pluginIndexMap.get(a) ?? -1 + const indexB = pluginIndexMap.get(b) ?? -1 + 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/plugin-runtime.ts b/cli/src/plugin-runtime.ts index b8c2e967..6049032f 100644 --- a/cli/src/plugin-runtime.ts +++ b/cli/src/plugin-runtime.ts @@ -1,4 +1,4 @@ -import type {OutputCleanContext, OutputWriteContext} from '@truenine/plugin-shared' +import type {OutputCleanContext, OutputWriteContext} from './plugins/plugin-shared' /** * Plugin Runtime Entry Point * @@ -15,7 +15,6 @@ import type {PipelineConfig} from '@/config' import * as fs from 'node:fs' import * as path from 'node:path' import process from 'node:process' -import {createLogger, setGlobalLogLevel} from '@truenine/plugin-shared' import glob from 'fast-glob' import { CleanCommand, @@ -26,6 +25,7 @@ import { PluginsCommand } from '@/commands' import userPluginConfigPromise from './plugin.config' +import {createLogger, setGlobalLogLevel} from './plugins/plugin-shared' /** * Parse runtime arguments. @@ -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) }) diff --git a/cli/src/plugin.config.ts b/cli/src/plugin.config.ts index 1657877d..3e717f92 100644 --- a/cli/src/plugin.config.ts +++ b/cli/src/plugin.config.ts @@ -1,30 +1,11 @@ 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' 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,11 +17,31 @@ 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, + AindexInputPlugin, + CommandInputPlugin, + EditorConfigInputPlugin, + GitExcludeInputPlugin, + GitIgnoreInputPlugin, + GlobalMemoryInputPlugin, + JetBrainsConfigInputPlugin, + MarkdownWhitespaceCleanupEffectInputPlugin, + OrphanFileCleanupEffectInputPlugin, + ProjectPromptInputPlugin, + ReadmeMdInputPlugin, + RuleInputPlugin, + SkillInputPlugin, + SkillNonSrcFileSyncEffectInputPlugin, + SubAgentInputPlugin, + VSCodeConfigInputPlugin, + WorkspaceInputPlugin +} from '@/inputs' +import {TraeCNIDEOutputPlugin} from '@/plugins/plugin-trae-cn-ide' export default defineConfig({ plugins: [ new AgentsOutputPlugin(), - new AntigravityOutputPlugin(), new ClaudeCodeCLIOutputPlugin(), new CodexCLIOutputPlugin(), new JetBrainsAIAssistantCodexOutputPlugin(), @@ -50,6 +51,7 @@ export default defineConfig({ new OpencodeCLIOutputPlugin(), new QoderIDEPluginOutputPlugin(), new TraeIDEOutputPlugin(), + new TraeCNIDEOutputPlugin(), new WarpIDEOutputPlugin(), new WindsurfOutputPlugin(), new CursorOutputPlugin(), @@ -65,12 +67,12 @@ export default defineConfig({ new MarkdownWhitespaceCleanupEffectInputPlugin(), new WorkspaceInputPlugin(), - new ShadowProjectInputPlugin(), + new AindexInputPlugin(), new VSCodeConfigInputPlugin(), 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-agentskills-compact/GenericSkillsOutputPlugin.ts b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts index 2899d354..db1b1d1c 100644 --- a/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts +++ b/cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.ts @@ -4,14 +4,14 @@ import type { SkillPrompt, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import {Buffer} from 'node:buffer' import * as fs from 'node:fs' import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {FilePathKind} from '@truenine/plugin-shared' +import {FilePathKind} from '../plugin-shared' const PROJECT_SKILLS_DIR = '.agents/skills' const LEGACY_SKILLS_DIR = '.skills' // 旧路径,用于清理 @@ -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-agentsmd/AgentsOutputPlugin.ts b/cli/src/plugins/plugin-agentsmd/AgentsOutputPlugin.ts index c0c9a835..5dc0d059 100644 --- a/cli/src/plugins/plugin-agentsmd/AgentsOutputPlugin.ts +++ b/cli/src/plugins/plugin-agentsmd/AgentsOutputPlugin.ts @@ -3,8 +3,8 @@ import type { OutputWriteContext, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' const PROJECT_MEMORY_FILE = 'AGENTS.md' 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 8e1c8e80..00000000 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.property.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type {CollectedInputContext, OutputPluginContext, Project, RelativePath, RootPath, RulePrompt} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return {pathKind: FilePathKind.Relative, path: pathStr, basePath, getDirectoryName: () => pathStr, getAbsolutePath: () => path.join(basePath, pathStr)} -} - -class TestablePlugin extends ClaudeCodeCLIOutputPlugin { - private mockHomeDir: string | null = null - public setMockHomeDir(dir: string | null): void { this.mockHomeDir = dir } - protected override getHomeDir(): string { return this.mockHomeDir ?? super.getHomeDir() } - public testBuildRuleFileName(rule: RulePrompt): string { return (this as any).buildRuleFileName(rule) } - public testBuildRuleContent(rule: RulePrompt): string { return (this as any).buildRuleContent(rule) } -} - -function createMockRulePrompt(opts: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { - const content = opts.content ?? '# Rule body' - return {type: PromptKind.Rule, content, length: content.length, filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', ''), markdownContents: [], yamlFrontMatter: {description: 'ignored', globs: opts.globs}, series: opts.series, ruleName: opts.ruleName, globs: opts.globs, scope: opts.scope ?? 'global'} as RulePrompt -} - -const seriesGen = fc.stringMatching(/^[a-z0-9]{1,5}$/) -const ruleNameGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,14}$/) -const globGen = fc.stringMatching(/^[a-z*/.]{1,30}$/).filter(s => s.length > 0) -const globsGen = fc.array(globGen, {minLength: 1, maxLength: 5}) -const contentGen = fc.string({minLength: 1, maxLength: 200}).filter(s => s.trim().length > 0) - -describe('claudeCodeCLIOutputPlugin property tests', () => { - let tempDir: string, plugin: TestablePlugin, mockContext: OutputPluginContext - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-prop-')) - plugin = new TestablePlugin() - plugin.setMockHomeDir(tempDir) - mockContext = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: {type: PromptKind.GlobalMemory, content: 'mem', filePathKind: FilePathKind.Absolute, dir: createMockRelativePath('.', tempDir), markdownContents: []}, - fastCommands: [], - subAgents: [], - skills: [] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - }, 30000) - - afterEach(() => { - try { fs.rmSync(tempDir, {recursive: true, force: true}) } - catch {} - }) - - describe('rule file name format', () => { - it('should always produce rule-{series}-{ruleName}.md', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, async (series, ruleName) => { - const rule = createMockRulePrompt({series, ruleName, globs: []}) - const fileName = plugin.testBuildRuleFileName(rule) - expect(fileName).toBe(`rule-${series}-${ruleName}.md`) - expect(fileName).toMatch(/^rule-.[^-\n\r\u2028\u2029]*-.+\.md$/) - }), {numRuns: 100}) - }) - }) - - describe('rule content format constraints', () => { - it('should never contain globs field in frontmatter', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).not.toMatch(/^globs:/m) - }), {numRuns: 100}) - }) - - it('should use paths field when globs are present', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).toContain('paths:') - }), {numRuns: 100}) - }) - - it('should wrap frontmatter in --- delimiters when globs exist', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - const lines = output.split('\n') - expect(lines[0]).toBe('---') - expect(lines.indexOf('---', 1)).toBeGreaterThan(0) - }), {numRuns: 100}) - }) - - it('should have no frontmatter when globs are empty', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, contentGen, async (series, ruleName, content) => { - const rule = createMockRulePrompt({series, ruleName, globs: [], content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).not.toContain('---') - expect(output).toBe(content) - }), {numRuns: 100}) - }) - - it('should preserve rule body content after frontmatter', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - expect(output).toContain(content) - }), {numRuns: 100}) - }) - - it('should list each glob as a YAML array item under paths', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, content}) - const output = plugin.testBuildRuleContent(rule) - for (const g of globs) expect(output).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 ~/.claude/rules/', async () => { - await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { - const rule = createMockRulePrompt({series, ruleName, globs, scope: 'global', content}) - const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, rules: [rule]}} as any - await plugin.writeGlobalOutputs(ctx) - const filePath = path.join(tempDir, '.claude', 'rules', `rule-${series}-${ruleName}.md`) - expect(fs.existsSync(filePath)).toBe(true) - const written = fs.readFileSync(filePath, 'utf8') - expect(written).toContain('paths:') - expect(written).not.toMatch(/^globs:/m) - expect(written).toContain(content) - for (const g of globs) expect(written).toMatch(new RegExp(`-\\s+['"]?${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?`)) // Accept quoted or unquoted formats - }), {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 eda325c0..00000000 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import type {CollectedInputContext, FastCommandPrompt, OutputPluginContext, Project, RelativePath, RootPath, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { // Helper to create mock RelativePath - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -class TestableClaudeCodeCLIOutputPlugin extends ClaudeCodeCLIOutputPlugin { // Testable subclass to mock home dir - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } - - public testBuildRuleFileName(rule: RulePrompt): string { - return (this as any).buildRuleFileName(rule) - } - - public testBuildRuleContent(rule: RulePrompt): string { - return (this as any).buildRuleContent(rule) - } -} - -function createMockRulePrompt(options: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { - const content = options.content ?? '# Rule body' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', ''), - markdownContents: [], - yamlFrontMatter: {description: 'ignored', globs: options.globs}, - series: options.series, - ruleName: options.ruleName, - globs: options.globs, - scope: options.scope ?? 'global' - } as RulePrompt -} - -describe('claudeCodeCLIOutputPlugin', () => { - let tempDir: string, - plugin: TestableClaudeCodeCLIOutputPlugin, - mockContext: OutputPluginContext - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-')) - plugin = new TestableClaudeCodeCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - mockContext = { - collectedInputContext: { - workspace: { - projects: [], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: { - type: PromptKind.GlobalMemory, - content: 'Global Memory Content', - filePathKind: FilePathKind.Absolute, - dir: createMockRelativePath('.', tempDir), - markdownContents: [] - }, - fastCommands: [], - subAgents: [], - skills: [] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - }, 30000) - - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch { - } // ignore cleanup errors - } - }) - - describe('registerGlobalOutputDirs', () => { - it('should register commands, agents, and skills subdirectories in .claude', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - - const dirPaths = dirs.map(d => d.path) - expect(dirPaths).toContain('commands') - expect(dirPaths).toContain('agents') - expect(dirPaths).toContain('skills') - - const expectedBasePath = path.join(tempDir, '.claude') - dirs.forEach(d => expect(d.basePath).toBe(expectedBasePath)) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should register project cleanup directories', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - rootMemoryPrompt: { - type: PromptKind.ProjectRootMemory, - content: 'content', - filePathKind: FilePathKind.Root, - dir: createMockRelativePath('.', tempDir) as unknown as RootPath, - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} - }, - childMemoryPrompts: [], - sourceFiles: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const dirs = await plugin.registerProjectOutputDirs(ctxWithProject) - const dirPaths = dirs.map(d => d.path) // (Or possibly more if logic changed, but based on code, it loops subdirs) // Expect 3 dirs: .claude/commands, .claude/agents, .claude/skills - - expect(dirPaths.some(p => p.includes(path.join('.claude', 'commands')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.claude', 'agents')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.claude', 'skills')))).toBe(true) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register CLAUDE.md in global config dir', async () => { - const files = await plugin.registerGlobalOutputFiles(mockContext) - const outputFile = files.find(f => f.path === 'CLAUDE.md') - expect(outputFile).toBeDefined() - expect(outputFile?.basePath).toBe(path.join(tempDir, '.claude')) - }) - - it('should register fast commands in commands subdirectory', async () => { - const mockCmd: FastCommandPrompt = { - type: PromptKind.FastCommand, - commandName: 'test-cmd', - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-cmd', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, description: 'desc'} - } - - const ctxWithCmd = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - fastCommands: [mockCmd] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithCmd) - const cmdFile = files.find(f => f.path.includes('test-cmd.md')) - - expect(cmdFile).toBeDefined() - expect(cmdFile?.path).toContain('commands') - expect(cmdFile?.basePath).toBe(path.join(tempDir, '.claude')) - }) - - it('should register sub agents in agents subdirectory', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-agent.md', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'agent', description: 'desc'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('test-agent.md')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('agents') - expect(agentFile?.basePath).toBe(path.join(tempDir, '.claude')) - }) - - it('should strip .mdx suffix from sub agent path and use .md', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'agent content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('code-review.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'desc'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('agents')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('code-review.cn.md') - expect(agentFile?.path).not.toContain('.mdx') - }) - - it('should register skills in skills subdirectory', async () => { - const mockSkill: SkillPrompt = { - type: PromptKind.Skill, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-skill', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'test-skill', description: 'desc'} - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) - const skillFile = files.find(f => f.path.includes('SKILL.md')) - - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain('skills') - expect(skillFile?.basePath).toBe(path.join(tempDir, '.claude')) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should only register project CLAUDE.md files', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - rootMemoryPrompt: { - type: PromptKind.ProjectRootMemory, - content: 'content', - filePathKind: FilePathKind.Root, - dir: createMockRelativePath('.', tempDir) as unknown as RootPath, - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} - }, - childMemoryPrompts: [], - sourceFiles: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const files = await plugin.registerProjectOutputFiles(ctxWithProject) - - expect(files).toHaveLength(1) - expect(files[0].path).toBe(path.join('project-a', 'CLAUDE.md')) - expect(files[0].basePath).toBe(tempDir) - }) - }) - - describe('writeGlobalOutputs', () => { - it('should write sub agent file with .md extension when source has .mdx', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: '# Code Review Agent', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('reviewer.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'reviewer', description: 'desc'} - } - - const writeCtx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const results = await plugin.writeGlobalOutputs(writeCtx) - const agentResult = results.files.find(f => f.path.path === 'reviewer.cn.md') - - expect(agentResult).toBeDefined() - expect(agentResult?.success).toBe(true) - - const writtenPath = path.join(tempDir, '.claude', 'agents', 'reviewer.cn.md') - expect(fs.existsSync(writtenPath)).toBe(true) - expect(fs.existsSync(path.join(tempDir, '.claude', 'agents', 'reviewer.cn.mdx'))).toBe(false) - expect(fs.existsSync(path.join(tempDir, '.claude', 'agents', 'reviewer.cn.mdx.md'))).toBe(false) - }) - }) - - describe('buildRuleFileName', () => { - it('should produce rule-{series}-{ruleName}.md', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'naming', globs: []}) - expect(plugin.testBuildRuleFileName(rule)).toBe('rule-01-naming.md') - }) - }) - - describe('buildRuleContent', () => { - it('should return plain content when globs is empty', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: [], content: '# No globs'}) - expect(plugin.testBuildRuleContent(rule)).toBe('# No globs') - }) - - it('should use paths field (not globs) in YAML frontmatter per Claude Code docs', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], content: '# TS rule'}) - const content = plugin.testBuildRuleContent(rule) - expect(content).toContain('paths:') - expect(content).not.toMatch(/^globs:/m) - }) - - it('should output paths as YAML array items', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts', '**/*.tsx'], content: '# Body'}) - const content = plugin.testBuildRuleContent(rule) - expect(content).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 register rules subdir in global output dirs when global rules exist', async () => { - const ctx = { - ...mockContext, - collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} - } - const dirs = await plugin.registerGlobalOutputDirs(ctx) - expect(dirs.map(d => d.path)).toContain('rules') - }) - - it('should not register rules subdir when no global rules', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - expect(dirs.map(d => d.path)).not.toContain('rules') - }) - - it('should register global rule files in ~/.claude/rules/', async () => { - const ctx = { - ...mockContext, - collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global'})]} - } - const files = await plugin.registerGlobalOutputFiles(ctx) - const ruleFile = files.find(f => f.path === 'rule-01-ts.md') - expect(ruleFile).toBeDefined() - expect(ruleFile?.basePath).toBe(path.join(tempDir, '.claude', 'rules')) - }) - - it('should not register project rules as global files', async () => { - const ctx = { - ...mockContext, - collectedInputContext: {...mockContext.collectedInputContext, rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'project'})]} - } - const files = await plugin.registerGlobalOutputFiles(ctx) - expect(files.find(f => f.path.includes('rule-'))).toBeUndefined() - }) - }) - - describe('canWrite with rules', () => { - it('should return true when rules exist even without other content', async () => { - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - globalMemory: void 0, - rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: []})] - } - } - expect(await plugin.canWrite(ctx as any)).toBe(true) - }) - }) - - describe('writeGlobalOutputs with rules', () => { - it('should write global rule file to ~/.claude/rules/', async () => { - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - rules: [createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], scope: 'global', content: '# TS rule'})] - } - } - const results = await plugin.writeGlobalOutputs(ctx as any) - const ruleResult = results.files.find(f => f.path.path === 'rule-01-ts.md') - expect(ruleResult?.success).toBe(true) - - const filePath = path.join(tempDir, '.claude', 'rules', 'rule-01-ts.md') - expect(fs.existsSync(filePath)).toBe(true) - const content = fs.readFileSync(filePath, 'utf8') - expect(content).toContain('paths:') - expect(content).toContain('# TS rule') - }) - - it('should write rule without frontmatter when globs is empty', async () => { - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - rules: [createMockRulePrompt({series: '01', ruleName: 'general', globs: [], scope: 'global', content: '# Always apply'})] - } - } - await plugin.writeGlobalOutputs(ctx as any) - const filePath = path.join(tempDir, '.claude', 'rules', 'rule-01-general.md') - const content = fs.readFileSync(filePath, 'utf8') - expect(content).toBe('# Always apply') - expect(content).not.toContain('---') - }) - }) - - describe('writeProjectOutputs with rules', () => { - it('should write project rule file to {project}/.claude/rules/', async () => { - const mockProject: Project = { - name: 'proj', - dirFromWorkspacePath: createMockRelativePath('proj', tempDir), - rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Root, dir: createMockRelativePath('.', tempDir) as unknown as RootPath, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}}, - childMemoryPrompts: [], - sourceFiles: [] - } - const ctx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, - rules: [createMockRulePrompt({series: '02', ruleName: 'api', globs: ['src/api/**'], scope: 'project', content: '# API rules'})] - } - } - const results = await plugin.writeProjectOutputs(ctx as any) - expect(results.files.some(f => f.path.path === 'rule-02-api.md' && f.success)).toBe(true) - - const filePath = path.join(tempDir, 'proj', '.claude', 'rules', 'rule-02-api.md') - expect(fs.existsSync(filePath)).toBe(true) - const content = fs.readFileSync(filePath, 'utf8') - expect(content).toContain('paths:') - expect(content).toContain('# API rules') - }) - }) -}) diff --git a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts index 050b9145..6c9f139d 100644 --- a/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-claude-code-cli/ClaudeCodeCLIOutputPlugin.ts @@ -1,5 +1,5 @@ -import type {OutputPluginContext, OutputWriteContext, RulePrompt, WriteResults} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +import type {OutputPluginContext, OutputWriteContext, RulePrompt, WriteResults} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import * as path from 'node:path' import {buildMarkdownWithFrontMatter, doubleQuoted} from '@truenine/md-compiler/markdown' import {applySubSeriesGlobPrefix, BaseCLIOutputPlugin, filterRulesByProjectConfig} from '@truenine/plugin-output-shared' @@ -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,35 +27,30 @@ 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 }) } - 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) } - 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 +60,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 +77,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 +94,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 +105,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-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 4c65e6ff..64013bce 100644 --- a/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts +++ b/cli/src/plugins/plugin-cursor/CursorOutputPlugin.ts @@ -1,37 +1,43 @@ import type { - FastCommandPrompt, + CommandPrompt, OutputPluginContext, OutputWriteContext, - Project, RulePrompt, SkillPrompt, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import {Buffer} from 'node:buffer' import * as fs from 'node:fs' import * as path from 'node:path' import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' -import {AbstractOutputPlugin, applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} 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' -]) +import { + AbstractOutputPlugin, + applySubSeriesGlobPrefix, + filterCommandsByProjectConfig, + filterRulesByProjectConfig, + filterSkillsByProjectConfig, + GlobalConfigDirs, + IgnoreFiles, + McpConfigManager, + OutputFileNames, + OutputPrefixes, + OutputSubdirectories, + PreservedSkills, + transformMcpConfigForCursor +} from '@truenine/plugin-output-shared' +import {FilePathKind, PLUGIN_NAMES} from '../plugin-shared' + +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() { @@ -39,7 +45,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 => { @@ -69,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}) @@ -101,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) @@ -111,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}) } @@ -197,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 @@ -209,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[] = [] @@ -226,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') @@ -281,107 +287,55 @@ 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) } 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 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 { - 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 mcpManager = new McpConfigManager({fs, logger: this.log}) + const servers = mcpManager.collectMcpServers(skills) - 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} + if (servers.size === 0) return null - let existingConfig: Record = {} - try { if (this.existsSync(mcpConfigPath)) existingConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')) as Record } - catch { existingConfig = {} } + const transformed = mcpManager.transformMcpServers(servers, transformMcpConfigForCursor) - const existingMcpServers = (existingConfig['mcpServers'] as Record) ?? {} - existingConfig['mcpServers'] = {...existingMcpServers, ...mergedMcpServers} - const content = JSON.stringify(existingConfig, null, 2) + const globalDir = this.getGlobalConfigDir() + const mcpConfigPath = path.join(globalDir, MCP_CONFIG_FILE) - 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 result = mcpManager.writeCursorMcpConfig(mcpConfigPath, transformed, ctx.dryRun === true) - 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} + 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} } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - this.log.error({action: 'write', type: 'globalMcpConfig', path: mcpConfigPath, error: errMsg}) - return {path: relativePath, success: false, error: error as Error} - } - } - - private transformMcpConfigForCursor(config: Record): Record { - const result: Record = {} - if (config['command'] != null) { - result['command'] = config['command'] - if (config['args'] != null) result['args'] = config['args'] - if (config['env'] != null) result['env'] = config['env'] - return result - } - const url = config['url'] ?? config['serverUrl'] - if (url == null) return result - result['url'] = url - if (config['headers'] != null) result['headers'] = config['headers'] - return result } private async writeGlobalSkill(ctx: OutputWriteContext, skillsDir: string, skill: SkillPrompt): Promise { @@ -418,30 +372,19 @@ 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) 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} } } @@ -492,8 +435,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 +451,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-droid-cli/DroidCLIOutputPlugin.test.ts b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts deleted file mode 100644 index 0cad3c85..00000000 --- a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import type {CollectedInputContext, FastCommandPrompt, OutputPluginContext, Project, RelativePath, RootPath, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {DroidCLIOutputPlugin} from './DroidCLIOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { // Helper to create mock RelativePath - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -class TestableDroidCLIOutputPlugin extends DroidCLIOutputPlugin { // Testable subclass to mock home dir - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -describe('droidCLIOutputPlugin', () => { - let tempDir: string, - plugin: TestableDroidCLIOutputPlugin, - mockContext: OutputPluginContext - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'droid-test-')) - plugin = new TestableDroidCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - mockContext = { - collectedInputContext: { - workspace: { - projects: [], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: { - type: PromptKind.GlobalMemory, - content: 'Global Memory Content', - filePathKind: FilePathKind.Absolute, - dir: createMockRelativePath('.', tempDir), - markdownContents: [] - }, - fastCommands: [], - subAgents: [], - skills: [] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - }, 30000) - - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch { - } // ignore cleanup errors - } - }) - - describe('registerGlobalOutputDirs', () => { - it('should register commands, agents, and skills subdirectories in .factory', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - - const dirPaths = dirs.map(d => d.path) - expect(dirPaths).toContain('commands') - expect(dirPaths).toContain('agents') - expect(dirPaths).toContain('skills') - - const expectedBasePath = path.join(tempDir, '.factory') - dirs.forEach(d => expect(d.basePath).toBe(expectedBasePath)) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should register project cleanup directories', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - rootMemoryPrompt: { - type: PromptKind.ProjectRootMemory, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir) as unknown as RootPath, - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} - }, - childMemoryPrompts: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const dirs = await plugin.registerProjectOutputDirs(ctxWithProject) - const dirPaths = dirs.map(d => d.path) // Expect 3 dirs: .factory/commands, .factory/agents, .factory/skills - - expect(dirPaths.some(p => p.includes(path.join('.factory', 'commands')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.factory', 'agents')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.factory', 'skills')))).toBe(true) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register AGENTS.md in global config dir', async () => { - const files = await plugin.registerGlobalOutputFiles(mockContext) - const outputFile = files.find(f => f.path === 'AGENTS.md') - - expect(outputFile).toBeDefined() - expect(outputFile?.basePath).toBe(path.join(tempDir, '.factory')) - }) - - it('should register fast commands in commands subdirectory', async () => { - const mockCmd: FastCommandPrompt = { - type: PromptKind.FastCommand, - commandName: 'test-cmd', - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-cmd', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, description: 'desc'} - } - - const ctxWithCmd = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - fastCommands: [mockCmd] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithCmd) - const cmdFile = files.find(f => f.path.includes('test-cmd.md')) - - expect(cmdFile).toBeDefined() - expect(cmdFile?.path).toContain('commands') - expect(cmdFile?.basePath).toBe(path.join(tempDir, '.factory')) - }) - - it('should register sub agents in agents subdirectory', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-agent.md', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'agent', description: 'desc'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('test-agent.md')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('agents') - expect(agentFile?.basePath).toBe(path.join(tempDir, '.factory')) - }) - - it('should strip .mdx suffix from sub agent path and use .md', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'agent content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('code-review.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'desc'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('agents')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('code-review.cn.md') - expect(agentFile?.path).not.toContain('.mdx') - }) - - it('should register skills in skills subdirectory', async () => { - const mockSkill: SkillPrompt = { - type: PromptKind.Skill, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-skill', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'test-skill', description: 'desc'} - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) - const skillFile = files.find(f => f.path.includes('SKILL.md')) - - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain('skills') - expect(skillFile?.basePath).toBe(path.join(tempDir, '.factory')) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should return empty array', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - childMemoryPrompts: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const files = await plugin.registerProjectOutputFiles(ctxWithProject) - expect(files).toEqual([]) - }) - }) -}) diff --git a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts index 3d4333db..023bc4eb 100644 --- a/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-droid-cli/DroidCLIOutputPlugin.ts @@ -2,7 +2,7 @@ import type { OutputWriteContext, SkillPrompt, WriteResult -} from '@truenine/plugin-shared' +} from '../plugin-shared' import * as path from 'node:path' import {BaseCLIOutputPlugin} from '@truenine/plugin-output-shared' @@ -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-editorconfig/EditorConfigOutputPlugin.ts b/cli/src/plugins/plugin-editorconfig/EditorConfigOutputPlugin.ts index 77e8b575..9308f33d 100644 --- a/cli/src/plugins/plugin-editorconfig/EditorConfigOutputPlugin.ts +++ b/cli/src/plugins/plugin-editorconfig/EditorConfigOutputPlugin.ts @@ -3,10 +3,10 @@ import type { OutputWriteContext, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {FilePathKind} from '@truenine/plugin-shared' +import {FilePathKind} from '../plugin-shared' const EDITOR_CONFIG_FILE = '.editorconfig' 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-git-exclude/GitExcludeOutputPlugin.ts b/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.ts index de19e3d4..07f7038f 100644 --- a/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.ts +++ b/cli/src/plugins/plugin-git-exclude/GitExcludeOutputPlugin.ts @@ -3,12 +3,12 @@ import type { OutputWriteContext, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import * as fs from 'node:fs' import * as path from 'node:path' import {AbstractOutputPlugin, findAllGitRepos, findGitModuleInfoDirs, resolveGitInfoDir} from '@truenine/plugin-output-shared' -import {FilePathKind} from '@truenine/plugin-shared' +import {FilePathKind} from '../plugin-shared' export class GitExcludeOutputPlugin extends AbstractOutputPlugin { constructor() { 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 b8427122..00000000 --- a/cli/src/plugins/plugin-input-agentskills/SkillInputPlugin.ts +++ /dev/null @@ -1,476 +0,0 @@ -import type {CollectedInputContext, ILogger, InputPluginContext, McpServerConfig, SkillChildDoc, SkillMcpConfig, SkillPrompt, SkillResource, SkillResourceCategory, SkillResourceEncoding, SkillYAMLFrontMatter} from '@truenine/plugin-shared' - -import {Buffer} from 'node:buffer' -import * as path from 'node:path' -import {mdxToMd} from '@truenine/md-compiler' -import {MetadataValidationError} from '@truenine/md-compiler/errors' -import {parseMarkdown, transformMdxReferencesToMd} from '@truenine/md-compiler/markdown' -import {AbstractInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind, - PromptKind, - SKILL_RESOURCE_BINARY_EXTENSIONS, - validateSkillMetadata -} from '@truenine/plugin-shared' - -function isBinaryResourceExtension(ext: string): boolean { - return (SKILL_RESOURCE_BINARY_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) -} - -function getResourceCategory(ext: string): SkillResourceCategory { - const lowerExt = ext.toLowerCase() - - const imageExtensions = [ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.ico', - '.bmp', - '.tiff', - '.svg' - ] - if (imageExtensions.includes(lowerExt)) return 'image' - - const codeExtensions = [ - '.kt', - '.java', - '.py', - '.pyi', - '.pyx', - '.ts', - '.tsx', - '.js', - '.jsx', - '.mjs', - '.cjs', - '.go', - '.rs', - '.c', - '.cpp', - '.cc', - '.h', - '.hpp', - '.hxx', - '.cs', - '.fs', - '.fsx', - '.vb', - '.rb', - '.php', - '.swift', - '.scala', - '.groovy', - '.lua', - '.r', - '.jl', - '.ex', - '.exs', - '.erl', - '.clj', - '.cljs', - '.hs', - '.ml', - '.mli', - '.nim', - '.zig', - '.v', - '.dart', - '.vue', - '.svelte', - '.d.ts', - '.d.mts', - '.d.cts' - ] - if (codeExtensions.includes(lowerExt)) return 'code' - - const dataExtensions = [ - '.sql', - '.json', - '.jsonc', - '.json5', - '.xml', - '.xsd', - '.xsl', - '.xslt', - '.yaml', - '.yml', - '.toml', - '.csv', - '.tsv', - '.graphql', - '.gql', - '.proto' - ] - if (dataExtensions.includes(lowerExt)) return 'data' - - const documentExtensions = [ - '.txt', - '.text', - '.rtf', - '.log', - '.docx', - '.doc', - '.xlsx', - '.xls', - '.pptx', - '.ppt', - '.pdf', - '.odt', - '.ods', - '.odp' - ] - if (documentExtensions.includes(lowerExt)) return 'document' - - const configExtensions = [ - '.ini', - '.conf', - '.cfg', - '.config', - '.properties', - '.env', - '.envrc', - '.editorconfig', - '.gitignore', - '.gitattributes', - '.npmrc', - '.nvmrc', - '.npmignore', - '.eslintrc', - '.prettierrc', - '.stylelintrc', - '.babelrc', - '.browserslistrc' - ] - if (configExtensions.includes(lowerExt)) return 'config' - - const scriptExtensions = [ - '.sh', - '.bash', - '.zsh', - '.fish', - '.ps1', - '.psm1', - '.psd1', - '.bat', - '.cmd' - ] - if (scriptExtensions.includes(lowerExt)) return 'script' - - const binaryExtensions = [ - '.exe', - '.dll', - '.so', - '.dylib', - '.bin', - '.wasm', - '.class', - '.jar', - '.war', - '.pyd', - '.pyc', - '.pyo', - '.zip', - '.tar', - '.gz', - '.bz2', - '.7z', - '.rar', - '.ttf', - '.otf', - '.woff', - '.woff2', - '.eot', - '.db', - '.sqlite', - '.sqlite3' - ] - if (binaryExtensions.includes(lowerExt)) return 'binary' - - return 'other' -} - -function getMimeType(ext: string): string | void { - const mimeTypes: Record = { - '.ts': 'text/typescript', - '.tsx': 'text/typescript', - '.js': 'text/javascript', - '.jsx': 'text/javascript', - '.json': 'application/json', - '.py': 'text/x-python', - '.java': 'text/x-java', - '.kt': 'text/x-kotlin', - '.go': 'text/x-go', - '.rs': 'text/x-rust', - '.c': 'text/x-c', - '.cpp': 'text/x-c++', - '.cs': 'text/x-csharp', - '.rb': 'text/x-ruby', - '.php': 'text/x-php', - '.swift': 'text/x-swift', - '.scala': 'text/x-scala', - '.sql': 'application/sql', - '.xml': 'application/xml', - '.yaml': 'text/yaml', - '.yml': 'text/yaml', - '.toml': 'text/toml', - '.csv': 'text/csv', - '.graphql': 'application/graphql', - '.txt': 'text/plain', - '.pdf': 'application/pdf', - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - '.html': 'text/html', - '.css': 'text/css', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.ico': 'image/x-icon', - '.bmp': 'image/bmp' - } - return mimeTypes[ext.toLowerCase()] -} - -export class SkillInputPlugin extends AbstractInputPlugin { - constructor() { - super('SkillInputPlugin') - } - - readMcpConfig( - skillDir: string, - fs: typeof import('node:fs'), - logger: ILogger - ): SkillMcpConfig | void { - const mcpJsonPath = path.join(skillDir, 'mcp.json') - - if (!fs.existsSync(mcpJsonPath)) return void 0 - - if (!fs.statSync(mcpJsonPath).isFile()) { - logger.warn('mcp.json is not a file', {skillDir}) - return void 0 - } - - try { - const rawContent = fs.readFileSync(mcpJsonPath, 'utf8') - const parsed = JSON.parse(rawContent) as {mcpServers?: Record} - - if (parsed.mcpServers == null || typeof parsed.mcpServers !== 'object') { - logger.warn('mcp.json missing mcpServers field', {skillDir}) - return void 0 - } - - return { - type: PromptKind.SkillMcpConfig, - mcpServers: parsed.mcpServers, - rawContent - } - } - catch (e) { - logger.warn('failed to parse mcp.json', {skillDir, error: e}) - return void 0 - } - } - - scanSkillDirectory( - skillDir: string, - fs: typeof import('node:fs'), - logger: ILogger, - currentRelativePath: string = '' - ): {childDocs: SkillChildDoc[], resources: SkillResource[]} { - const childDocs: SkillChildDoc[] = [] - const resources: SkillResource[] = [] - - const currentDir = currentRelativePath - ? path.join(skillDir, currentRelativePath) - : skillDir - - try { - const entries = fs.readdirSync(currentDir, {withFileTypes: true}) - - for (const entry of entries) { - const relativePath = currentRelativePath - ? `${currentRelativePath}/${entry.name}` - : entry.name - - if (entry.isDirectory()) { - const subResult = this.scanSkillDirectory(skillDir, fs, logger, relativePath) - childDocs.push(...subResult.childDocs) - resources.push(...subResult.resources) - } else if (entry.isFile()) { - const filePath = path.join(currentDir, entry.name) - - if (entry.name.endsWith('.mdx')) { - if (currentRelativePath === '' && entry.name === 'skill.mdx') continue - - try { - const rawContent = fs.readFileSync(filePath, 'utf8') - const parsed = parseMarkdown(rawContent) - const content = transformMdxReferencesToMd(parsed.contentWithoutFrontMatter) - - childDocs.push({ - type: PromptKind.SkillChildDoc, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - relativePath, - dir: { - pathKind: FilePathKind.Relative, - path: relativePath, - basePath: skillDir, - getDirectoryName: () => path.dirname(relativePath), - getAbsolutePath: () => filePath - } - } as SkillChildDoc) - } - catch (e) { - logger.warn('failed to read child doc', {path: relativePath, error: e}) - } - } else { - if (currentRelativePath === '' && entry.name === 'mcp.json') continue - - const ext = path.extname(entry.name) - let content: string, - encoding: SkillResourceEncoding, - length: number - - try { - if (isBinaryResourceExtension(ext)) { - const buffer = fs.readFileSync(filePath) - content = buffer.toString('base64') - encoding = 'base64' - ;({length} = buffer) - } else { - content = fs.readFileSync(filePath, 'utf8') - encoding = 'text' - ;({length} = Buffer.from(content, 'utf8')) - } - - const mimeType = getMimeType(ext) - const resource: SkillResource = { - type: PromptKind.SkillResource, - extension: ext, - fileName: entry.name, - relativePath, - content, - encoding, - category: getResourceCategory(ext), - length - } - - if (mimeType != null) resources.push({...resource, mimeType}) - else resources.push(resource) - } - catch (e) { - logger.warn('failed to read resource file', {path: relativePath, error: e}) - } - } - } - } - } - catch (e) { - logger.warn('failed to scan directory', {path: currentDir, error: e}) - } - - return {childDocs, resources} - } - - async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger, globalScope} = ctx - const {shadowProjectDir} = this.resolveBasePaths(options) - - const skillDir = this.resolveShadowPath(options.shadowSourceProject.skill.dist, shadowProjectDir) - - const skills: SkillPrompt[] = [] - if (!(ctx.fs.existsSync(skillDir) && ctx.fs.statSync(skillDir).isDirectory())) return {skills} - - const entries = ctx.fs.readdirSync(skillDir, {withFileTypes: true}) - for (const entry of entries) { - if (entry.isDirectory()) { - const skillFilePath = ctx.path.join(skillDir, entry.name, 'skill.mdx') - if (ctx.fs.existsSync(skillFilePath) && ctx.fs.statSync(skillFilePath).isFile()) { - try { - const rawContent = ctx.fs.readFileSync(skillFilePath, 'utf8') - - const parsed = parseMarkdown(rawContent) - - const compileResult = await mdxToMd(rawContent, { - globalScope, - extractMetadata: true, - basePath: ctx.path.join(skillDir, entry.name) - }) - - const mergedFrontMatter: SkillYAMLFrontMatter = { - ...parsed.yamlFrontMatter, - ...compileResult.metadata.fields - } as SkillYAMLFrontMatter - - const validationResult = validateSkillMetadata( - mergedFrontMatter as Record, - skillFilePath - ) - - for (const warning of validationResult.warnings) logger.debug(warning) - - if (!validationResult.valid) throw new MetadataValidationError(validationResult.errors, skillFilePath) - - const content = transformMdxReferencesToMd(compileResult.content) - - const skillAbsoluteDir = ctx.path.join(skillDir, entry.name) - - const mcpConfig = this.readMcpConfig(skillAbsoluteDir, ctx.fs, logger) - - const {childDocs, resources} = this.scanSkillDirectory( - skillAbsoluteDir, - ctx.fs, - logger - ) - - logger.debug('skill metadata extracted', { - skill: entry.name, - source: compileResult.metadata.source, - hasYaml: parsed.yamlFrontMatter != null, - hasExport: Object.keys(compileResult.metadata.fields).length > 0 - }) - - const {seriName} = mergedFrontMatter - - skills.push({ - type: PromptKind.Skill, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - yamlFrontMatter: mergedFrontMatter.name != null - ? mergedFrontMatter - : {name: entry.name, description: ''} as SkillYAMLFrontMatter, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - ...mcpConfig != null && {mcpConfig}, - ...childDocs.length > 0 && {childDocs}, - ...resources.length > 0 && {resources}, - ...seriName != null && {seriName}, - dir: { - pathKind: FilePathKind.Relative, - path: entry.name, - basePath: skillDir, - getDirectoryName: () => entry.name, - getAbsolutePath: () => path.join(skillDir, entry.name) - } - }) - } - catch (e) { - logger.error('failed to parse skill', {file: skillFilePath, error: e}) - } - } - } - } - return {skills} - } -} diff --git a/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/FastCommandInputPlugin.ts b/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts deleted file mode 100644 index d9601fc9..00000000 --- a/cli/src/plugins/plugin-input-fast-command/FastCommandInputPlugin.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' -import type { - CollectedInputContext, - FastCommandPrompt, - FastCommandYAMLFrontMatter, - InputPluginContext, - MetadataValidationResult, - PluginOptions, - ResolvedBasePaths -} from '@truenine/plugin-shared' -import {mdxToMd} from '@truenine/md-compiler' -import {MetadataValidationError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {BaseDirectoryInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind, - PromptKind, - validateFastCommandMetadata -} from '@truenine/plugin-shared' - -export interface SeriesInfo { - readonly series?: string - readonly commandName: string -} - -export class FastCommandInputPlugin extends BaseDirectoryInputPlugin { - constructor() { - super('FastCommandInputPlugin', {configKey: 'shadowSourceProject.fastCommand.dist'}) - } - - protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { - return this.resolveShadowPath(options.shadowSourceProject.fastCommand.dist, resolvedPaths.shadowProjectDir) - } - - protected validateMetadata(metadata: Record, filePath: string): MetadataValidationResult { - return validateFastCommandMetadata(metadata, filePath) - } - - protected createResult(items: FastCommandPrompt[]): Partial { - return {fastCommands: items} - } - - extractSeriesInfo(fileName: string, parentDirName?: string): SeriesInfo { - const baseName = fileName.replace(/\.mdx$/, '') - - if (parentDirName != null) { - return { - series: parentDirName, - commandName: baseName - } - } - - const underscoreIndex = baseName.indexOf('_') - - if (underscoreIndex === -1) return {commandName: baseName} - - return { - series: baseName.slice(0, Math.max(0, underscoreIndex)), - commandName: baseName.slice(Math.max(0, underscoreIndex + 1)) - } - } - - override async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger, path, fs} = ctx - const resolvedPaths = this.resolveBasePaths(options) - - const targetDir = this.getTargetDir(options, resolvedPaths) - const items: FastCommandPrompt[] = [] - - if (!(fs.existsSync(targetDir) && fs.statSync(targetDir).isDirectory())) return this.createResult(items) - - try { - const entries = fs.readdirSync(targetDir, {withFileTypes: true}) - for (const entry of entries) { - if (entry.isFile() && entry.name.endsWith(this.extension)) { - const prompt = await this.processFile(entry.name, path.join(targetDir, entry.name), targetDir, void 0, ctx) - if (prompt != null) items.push(prompt) - } else if (entry.isDirectory()) { - const subDirPath = path.join(targetDir, entry.name) - try { - const subEntries = fs.readdirSync(subDirPath, {withFileTypes: true}) - for (const subEntry of subEntries) { - if (subEntry.isFile() && subEntry.name.endsWith(this.extension)) { - const prompt = await this.processFile(subEntry.name, path.join(subDirPath, subEntry.name), targetDir, entry.name, ctx) - if (prompt != null) items.push(prompt) - } - } - } catch (e) { - logger.error(`Failed to scan subdirectory at ${subDirPath}`, {error: e}) - } - } - } - } catch (e) { - logger.error(`Failed to scan directory at ${targetDir}`, {error: e}) - } - - return this.createResult(items) - } - - private async processFile( - fileName: string, - filePath: string, - baseDir: string, - parentDirName: string | undefined, - ctx: InputPluginContext - ): Promise { - const {logger, globalScope} = ctx - const rawContent = ctx.fs.readFileSync(filePath, 'utf8') - - try { - const parsed = parseMarkdown(rawContent) - - const compileResult = await mdxToMd(rawContent, { - globalScope, - extractMetadata: true, - basePath: parentDirName != null ? ctx.path.join(baseDir, parentDirName) : baseDir - }) - - const mergedFrontMatter: FastCommandYAMLFrontMatter | undefined = parsed.yamlFrontMatter != null || Object.keys(compileResult.metadata.fields).length > 0 - ? { - ...parsed.yamlFrontMatter, - ...compileResult.metadata.fields - } as FastCommandYAMLFrontMatter - : void 0 - - if (mergedFrontMatter != null) { - const validationResult = this.validateMetadata(mergedFrontMatter as Record, filePath) - - for (const warning of validationResult.warnings) logger.debug(warning) - - if (!validationResult.valid) throw new MetadataValidationError([...validationResult.errors], filePath) - } - - const {content} = compileResult - - const entryName = parentDirName != null ? `${parentDirName}/${fileName}` : fileName - - logger.debug(`${this.name} metadata extracted`, { - file: entryName, - source: compileResult.metadata.source, - hasYaml: parsed.yamlFrontMatter != null, - hasExport: Object.keys(compileResult.metadata.fields).length > 0 - }) - - return this.createPrompt( - entryName, - filePath, - content, - mergedFrontMatter, - parsed.rawFrontMatter, - parsed, - baseDir, - rawContent - ) - } catch (e) { - logger.error(`failed to parse ${this.name} item`, {file: filePath, error: e}) - return void 0 - } - } - - protected createPrompt( - entryName: string, - filePath: string, - content: string, - yamlFrontMatter: FastCommandYAMLFrontMatter | undefined, - rawFrontMatter: string | undefined, - parsed: ParsedMarkdown, - baseDir: string, - rawContent: string - ): FastCommandPrompt { - const slashIndex = entryName.indexOf('/') - const parentDirName = slashIndex !== -1 ? entryName.slice(0, slashIndex) : void 0 - const fileName = slashIndex !== -1 ? entryName.slice(slashIndex + 1) : entryName - - const seriesInfo = this.extractSeriesInfo(fileName, parentDirName) - const seriName = yamlFrontMatter?.seriName - - return { - type: PromptKind.FastCommand, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - ...yamlFrontMatter != null && {yamlFrontMatter}, - ...rawFrontMatter != null && {rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - dir: { - pathKind: FilePathKind.Relative, - path: entryName, - basePath: baseDir, - getDirectoryName: () => entryName.replace(/\.mdx$/, ''), - getAbsolutePath: () => filePath - }, - ...seriesInfo.series != null && {series: seriesInfo.series}, - commandName: seriesInfo.commandName, - ...seriName != null && {seriName}, - rawMdxContent: rawContent - } - } -} diff --git a/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 f70fc2b4..00000000 --- a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type {InputPluginContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {createLogger} from '@truenine/plugin-shared' -import {beforeEach, describe, expect, it, vi} from 'vitest' -import {GitIgnoreInputPlugin} from './GitIgnoreInputPlugin' - -vi.mock('node:fs') - -const BASE_OPTIONS = { - workspaceDir: '/workspace', - shadowSourceProject: { - name: '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() - - if (result.globalGitIgnore != null && result.globalGitIgnore.length > 0) { // Plugin uses @truenine/init-bundle template as fallback — may or may not have content - expect(result).toHaveProperty('globalGitIgnore') - } else expect(result).toEqual({}) - }) -}) diff --git a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts b/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts deleted file mode 100644 index ffb80a00..00000000 --- a/cli/src/plugins/plugin-input-gitignore/GitIgnoreInputPlugin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type {CollectedInputContext} from '@truenine/plugin-shared' -import * as path from 'node:path' -import {bundles} from '@truenine/init-bundle' -import {BaseFileInputPlugin} from '@truenine/plugin-input-shared' - -type BundleMap = Readonly> -const bundleMap = bundles as unknown as BundleMap - -function getGitignoreTemplate(): string | undefined { // 从 bundles 获取 gitignore 模板内容(public/exclude) - return bundleMap['public/gitignore']?.content -} - -/** - * Input plugin that reads gitignore content from shadow source project. - * Falls back to template from init-bundle if file doesn't exist. - */ -export class GitIgnoreInputPlugin extends BaseFileInputPlugin { - constructor() { - const template = getGitignoreTemplate() - super('GitIgnoreInputPlugin', template != null ? {fallbackContent: template} : {}) - } - - protected getFilePath(shadowProjectDir: string): string { - return path.join(shadowProjectDir, 'public', 'gitignore') - } - - protected getResultKey(): keyof CollectedInputContext { - return 'globalGitIgnore' - } -} diff --git a/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/RuleInputPlugin.ts b/cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts deleted file mode 100644 index 28842b48..00000000 --- a/cli/src/plugins/plugin-input-rule/RuleInputPlugin.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { - CollectedInputContext, - InputPluginContext, - MetadataValidationResult, - PluginOptions, - ResolvedBasePaths, - RulePrompt, - RuleScope, - RuleYAMLFrontMatter -} from '@truenine/plugin-shared' -import {mdxToMd} from '@truenine/md-compiler' -import {MetadataValidationError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {BaseDirectoryInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind, - PromptKind, - validateRuleMetadata -} from '@truenine/plugin-shared' - -export class RuleInputPlugin extends BaseDirectoryInputPlugin { - constructor() { - super('RuleInputPlugin', {configKey: 'shadowSourceProject.rule.dist'}) - } - - protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { - return this.resolveShadowPath(options.shadowSourceProject.rule.dist, resolvedPaths.shadowProjectDir) - } - - protected validateMetadata(metadata: Record, filePath: string): MetadataValidationResult { - return validateRuleMetadata(metadata, filePath) - } - - protected createResult(items: RulePrompt[]): Partial { - return {rules: items} - } - - protected createPrompt( - entryName: string, - filePath: string, - content: string, - yamlFrontMatter: RuleYAMLFrontMatter | undefined, - rawFrontMatter: string | undefined, - parsed: {markdownAst?: unknown, markdownContents: readonly unknown[]}, - baseDir: string, - rawContent: string - ): RulePrompt { - const slashIndex = entryName.indexOf('/') - const series = slashIndex !== -1 ? entryName.slice(0, slashIndex) : '' - const fileName = slashIndex !== -1 ? entryName.slice(slashIndex + 1) : entryName - const ruleName = fileName.replace(/\.mdx$/, '') - - const globs: readonly string[] = yamlFrontMatter?.globs ?? [] - const scope: RuleScope = yamlFrontMatter?.scope ?? 'project' - const seriName = yamlFrontMatter?.seriName - - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - ...yamlFrontMatter != null && {yamlFrontMatter}, - ...rawFrontMatter != null && {rawFrontMatter}, - markdownAst: parsed.markdownAst as never, - markdownContents: parsed.markdownContents as never, - dir: { - pathKind: FilePathKind.Relative, - path: entryName, - basePath: baseDir, - getDirectoryName: () => entryName.replace(/\.mdx$/, ''), - getAbsolutePath: () => filePath - }, - series, - ruleName, - globs, - scope, - ...seriName != null && {seriName}, - rawMdxContent: rawContent - } - } - - override async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger, path, fs} = ctx - const resolvedPaths = this.resolveBasePaths(options) - - const targetDir = this.getTargetDir(options, resolvedPaths) - const items: RulePrompt[] = [] - - if (!(fs.existsSync(targetDir) && fs.statSync(targetDir).isDirectory())) return this.createResult(items) - - try { - const entries = fs.readdirSync(targetDir, {withFileTypes: true}) - for (const entry of entries) { - if (entry.isDirectory()) { - const subDirPath = path.join(targetDir, entry.name) - try { - const subEntries = fs.readdirSync(subDirPath, {withFileTypes: true}) - for (const subEntry of subEntries) { - if (subEntry.isFile() && subEntry.name.endsWith(this.extension)) { - const prompt = await this.processFile(subEntry.name, path.join(subDirPath, subEntry.name), targetDir, entry.name, ctx) - if (prompt != null) items.push(prompt) - } - } - } catch (e) { - logger.error(`Failed to scan subdirectory at ${subDirPath}`, {error: e}) - } - } - } - } catch (e) { - logger.error(`Failed to scan directory at ${targetDir}`, {error: e}) - } - - return this.createResult(items) - } - - private async processFile( - fileName: string, - filePath: string, - baseDir: string, - parentDirName: string, - ctx: InputPluginContext - ): Promise { - const {logger, globalScope} = ctx - const rawContent = ctx.fs.readFileSync(filePath, 'utf8') - - try { - const parsed = parseMarkdown(rawContent) - - const compileResult = await mdxToMd(rawContent, { - globalScope, - extractMetadata: true, - basePath: ctx.path.join(baseDir, parentDirName) - }) - - const mergedFrontMatter: RuleYAMLFrontMatter | undefined = parsed.yamlFrontMatter != null || Object.keys(compileResult.metadata.fields).length > 0 - ? { - ...parsed.yamlFrontMatter, - ...compileResult.metadata.fields - } as RuleYAMLFrontMatter - : void 0 - - if (mergedFrontMatter != null) { - const validationResult = this.validateMetadata(mergedFrontMatter as Record, filePath) - - for (const warning of validationResult.warnings) logger.debug(warning) - - if (!validationResult.valid) throw new MetadataValidationError([...validationResult.errors], filePath) - } - - const {content} = compileResult - - const entryName = `${parentDirName}/${fileName}` - - logger.debug(`${this.name} metadata extracted`, { - file: entryName, - source: compileResult.metadata.source, - hasYaml: parsed.yamlFrontMatter != null, - hasExport: Object.keys(compileResult.metadata.fields).length > 0 - }) - - return this.createPrompt( - entryName, - filePath, - content, - mergedFrontMatter, - parsed.rawFrontMatter, - parsed, - baseDir, - rawContent - ) - } catch (e) { - logger.error(`failed to parse ${this.name} item`, {file: filePath, error: e}) - return void 0 - } - } -} diff --git a/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-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/AbstractInputPlugin.ts b/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.ts index 5466d6c0..6619d5c4 100644 --- a/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.ts +++ b/cli/src/plugins/plugin-input-shared/AbstractInputPlugin.ts @@ -11,7 +11,7 @@ import type { PluginScopeRegistration, ResolvedBasePaths, YAMLFrontMatter -} from '@truenine/plugin-shared' +} from '../plugin-shared' import {spawn} from 'node:child_process' import * as os from 'node:os' @@ -21,7 +21,7 @@ import { AbstractPlugin, PathPlaceholders, PluginKind -} from '@truenine/plugin-shared' +} from '../plugin-shared' export abstract class AbstractInputPlugin extends AbstractPlugin implements InputPlugin { private readonly inputEffects: InputEffectRegistration[] = [] @@ -42,7 +42,7 @@ export abstract class AbstractInputPlugin extends AbstractPlugin( diff --git a/cli/src/plugins/plugin-input-shared/BaseDirectoryInputPlugin.ts b/cli/src/plugins/plugin-input-shared/BaseDirectoryInputPlugin.ts index 2c468392..98a3aa20 100644 --- a/cli/src/plugins/plugin-input-shared/BaseDirectoryInputPlugin.ts +++ b/cli/src/plugins/plugin-input-shared/BaseDirectoryInputPlugin.ts @@ -5,7 +5,7 @@ import type { PluginOptions, ResolvedBasePaths, YAMLFrontMatter -} from '@truenine/plugin-shared' +} from '../plugin-shared' import {mdxToMd} from '@truenine/md-compiler' import {MetadataValidationError} from '@truenine/md-compiler/errors' import {parseMarkdown} from '@truenine/md-compiler/markdown' diff --git a/cli/src/plugins/plugin-input-shared/BaseFileInputPlugin.ts b/cli/src/plugins/plugin-input-shared/BaseFileInputPlugin.ts index c57b3fc8..68f1c53a 100644 --- a/cli/src/plugins/plugin-input-shared/BaseFileInputPlugin.ts +++ b/cli/src/plugins/plugin-input-shared/BaseFileInputPlugin.ts @@ -1,7 +1,7 @@ import type { CollectedInputContext, InputPluginContext -} from '@truenine/plugin-shared' +} from '../plugin-shared' import {AbstractInputPlugin} from './AbstractInputPlugin' /** @@ -28,8 +28,8 @@ export abstract class BaseFileInputPlugin extends AbstractInpu } collect(ctx: InputPluginContext): Partial { - const {shadowProjectDir} = this.resolveBasePaths(ctx.userConfigOptions) - const filePath = this.getFilePath(shadowProjectDir) + const {aindexDir} = this.resolveBasePaths(ctx.userConfigOptions) + const filePath = this.getFilePath(aindexDir) if (!ctx.fs.existsSync(filePath)) { if (this.options.fallbackContent != null) { 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..5aa391c7 --- /dev/null +++ b/cli/src/plugins/plugin-input-shared/LocalizedPromptReader.ts @@ -0,0 +1,441 @@ +import type {MdxGlobalScope} from '@truenine/md-compiler/globals' +import type { + DirectoryReadResult, + Locale, + LocalizedContent, + LocalizedPrompt, + LocalizedReadOptions, + Prompt, + PromptKind, + ReadError +} from '../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) + * + * 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( + private fs: typeof import('node:fs'), + private path: typeof import('node:path'), + private logger: import('../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[] = [] + + 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) + + const scanDirectory = async (currentSrcDir: string, currentDistDir: string, relativePath: string = ''): Promise => { + try { + const entries = this.fs.readdirSync(currentSrcDir, {withFileTypes: true}) + for (const entry of entries) { + const entryRelativePath = relativePath + ? this.path.join(relativePath, entry.name) + : entry.name + + if (entry.isDirectory()) { + const subSrcDir = this.path.join(currentSrcDir, entry.name) // Recursively scan subdirectories + const subDistDir = this.path.join(currentDistDir, entry.name) + await scanDirectory(subSrcDir, subDistDir, entryRelativePath) + continue + } + + 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(currentSrcDir, entry.name) + const fullName = relativePath // Use relative path as the name to preserve series/subdirectory info (e.g., "auqt/boot") + ? this.path.join(relativePath, baseName) + : baseName + + try { + const localized = await this.readFlatEntry( + fullName, + srcDir, + distDir, + fullName, + 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: currentSrcDir, + error: error as Error, + phase: 'scan' + }) + this.logger.error(`Failed to scan directory: ${currentSrcDir}`, {error}) + } + } + + await scanDirectory(srcDir, distDir) + + 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 + 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) // 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) + + const hasDist = distContent != null + const hasSrcZh = zhContent != null + const hasSrcEn = enContent != null + + 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' + } + + 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) + } + + return { + name, + type: kind, + src, + ...hasDist && {dist: distContent}, + metadata: { + hasDist, + hasMultipleLocales: hasSrcEn, + isDirectoryStructure, + ...children && children.length > 0 && {children} + }, + paths: { + ...(hasSrcZh || !hasDist) && {zh: srcZhPath}, + ...hasSrcEn && {en: srcEnPath}, + ...hasDist && {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 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 hasDist = distContent != null + const hasSrcZh = zhContent != null + const hasSrcEn = enContent != null + + 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, + hasMultipleLocales: hasSrcEn, + isDirectoryStructure: false + }, + paths: { + ...(hasSrcZh || !hasDist) && {zh: fullSrcZhPath}, + ...hasSrcEn && {en: fullSrcEnPath}, + ...hasDist && {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('../plugin-shared').ILogger, + globalScope?: MdxGlobalScope +): LocalizedPromptReader { + return new LocalizedPromptReader(fs, path, logger, globalScope) +} + +export { + type DirectoryReadResult, + type LocalizedReadOptions, + type ReadError +} from '../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-shared/scope/GlobalScopeCollector.ts b/cli/src/plugins/plugin-input-shared/scope/GlobalScopeCollector.ts index 2a2c06ed..af3cce4c 100644 --- a/cli/src/plugins/plugin-input-shared/scope/GlobalScopeCollector.ts +++ b/cli/src/plugins/plugin-input-shared/scope/GlobalScopeCollector.ts @@ -1,5 +1,5 @@ import type {EnvironmentContext, MdComponent, MdxGlobalScope, OsInfo, ToolReferences, UserProfile} from '@truenine/md-compiler/globals' // Collects and manages global scope variables for MDX expression evaluation. // src/scope/GlobalScopeCollector.ts -import type {UserConfigFile} from '@truenine/plugin-shared' +import type {UserConfigFile} from '../../plugin-shared' import * as os from 'node:os' import process from 'node:process' import {OsKind, ShellKind, ToolPresets} from '@truenine/md-compiler/globals' 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/SubAgentInputPlugin.ts b/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts deleted file mode 100644 index 132391b8..00000000 --- a/cli/src/plugins/plugin-input-subagent/SubAgentInputPlugin.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' -import type { - CollectedInputContext, - InputPluginContext, - MetadataValidationResult, - PluginOptions, - ResolvedBasePaths, - SubAgentPrompt, - SubAgentYAMLFrontMatter -} from '@truenine/plugin-shared' -import {mdxToMd} from '@truenine/md-compiler' -import {MetadataValidationError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' -import {BaseDirectoryInputPlugin} from '@truenine/plugin-input-shared' -import { - FilePathKind, - PromptKind, - validateSubAgentMetadata -} from '@truenine/plugin-shared' - -export interface SubAgentSeriesInfo { - readonly series?: string - readonly agentName: string -} - -export class SubAgentInputPlugin extends BaseDirectoryInputPlugin { - constructor() { - super('SubAgentInputPlugin', {configKey: 'shadowSourceProject.subAgent.dist'}) - } - - protected getTargetDir(options: Required, resolvedPaths: ResolvedBasePaths): string { - return this.resolveShadowPath(options.shadowSourceProject.subAgent.dist, resolvedPaths.shadowProjectDir) - } - - protected validateMetadata(metadata: Record, filePath: string): MetadataValidationResult { - return validateSubAgentMetadata(metadata, filePath) - } - - protected createResult(items: SubAgentPrompt[]): Partial { - return {subAgents: items} - } - - extractSeriesInfo(fileName: string, parentDirName?: string): SubAgentSeriesInfo { - const baseName = fileName.replace(/\.mdx$/, '') - - if (parentDirName != null) { - return { - series: parentDirName, - agentName: baseName - } - } - - const underscoreIndex = baseName.indexOf('_') - - if (underscoreIndex === -1) return {agentName: baseName} - - return { - series: baseName.slice(0, Math.max(0, underscoreIndex)), - agentName: baseName.slice(Math.max(0, underscoreIndex + 1)) - } - } - - override async collect(ctx: InputPluginContext): Promise> { - const {userConfigOptions: options, logger, path, fs} = ctx - const resolvedPaths = this.resolveBasePaths(options) - - const targetDir = this.getTargetDir(options, resolvedPaths) - const items: SubAgentPrompt[] = [] - - if (!(fs.existsSync(targetDir) && fs.statSync(targetDir).isDirectory())) return this.createResult(items) - - try { - const entries = fs.readdirSync(targetDir, {withFileTypes: true}) - for (const entry of entries) { - if (entry.isFile() && entry.name.endsWith(this.extension)) { - const prompt = await this.processFile(entry.name, path.join(targetDir, entry.name), targetDir, void 0, ctx) - if (prompt != null) items.push(prompt) - } else if (entry.isDirectory()) { - const subDirPath = path.join(targetDir, entry.name) - try { - const subEntries = fs.readdirSync(subDirPath, {withFileTypes: true}) - for (const subEntry of subEntries) { - if (subEntry.isFile() && subEntry.name.endsWith(this.extension)) { - const prompt = await this.processFile(subEntry.name, path.join(subDirPath, subEntry.name), targetDir, entry.name, ctx) - if (prompt != null) items.push(prompt) - } - } - } catch (e) { - logger.error(`Failed to scan subdirectory at ${subDirPath}`, {error: e}) - } - } - } - } catch (e) { - logger.error(`Failed to scan directory at ${targetDir}`, {error: e}) - } - - return this.createResult(items) - } - - private async processFile( - fileName: string, - filePath: string, - baseDir: string, - parentDirName: string | undefined, - ctx: InputPluginContext - ): Promise { - const {logger, globalScope} = ctx - const rawContent = ctx.fs.readFileSync(filePath, 'utf8') - - try { - const parsed = parseMarkdown(rawContent) - - const compileResult = await mdxToMd(rawContent, { - globalScope, - extractMetadata: true, - basePath: parentDirName != null ? ctx.path.join(baseDir, parentDirName) : baseDir - }) - - const mergedFrontMatter: SubAgentYAMLFrontMatter | undefined = parsed.yamlFrontMatter != null || Object.keys(compileResult.metadata.fields).length > 0 - ? { - ...parsed.yamlFrontMatter, - ...compileResult.metadata.fields - } as SubAgentYAMLFrontMatter - : void 0 - - if (mergedFrontMatter != null) { - const validationResult = this.validateMetadata(mergedFrontMatter as Record, filePath) - - for (const warning of validationResult.warnings) logger.debug(warning) - - if (!validationResult.valid) throw new MetadataValidationError([...validationResult.errors], filePath) - } - - const {content} = compileResult - - const entryName = parentDirName != null ? `${parentDirName}/${fileName}` : fileName - - logger.debug(`${this.name} metadata extracted`, { - file: entryName, - source: compileResult.metadata.source, - hasYaml: parsed.yamlFrontMatter != null, - hasExport: Object.keys(compileResult.metadata.fields).length > 0 - }) - - return this.createPrompt( - entryName, - filePath, - content, - mergedFrontMatter, - parsed.rawFrontMatter, - parsed, - baseDir, - rawContent - ) - } catch (e) { - logger.error(`failed to parse ${this.name} item`, {file: filePath, error: e}) - return void 0 - } - } - - protected createPrompt( - entryName: string, - filePath: string, - content: string, - yamlFrontMatter: SubAgentYAMLFrontMatter | undefined, - rawFrontMatter: string | undefined, - parsed: ParsedMarkdown, - baseDir: string, - rawContent: string - ): SubAgentPrompt { - const slashIndex = entryName.indexOf('/') - const parentDirName = slashIndex !== -1 ? entryName.slice(0, slashIndex) : void 0 - const fileName = slashIndex !== -1 ? entryName.slice(slashIndex + 1) : entryName - - const seriesInfo = this.extractSeriesInfo(fileName, parentDirName) - const seriName = yamlFrontMatter?.seriName - - return { - type: PromptKind.SubAgent, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - ...yamlFrontMatter != null && {yamlFrontMatter}, - ...rawFrontMatter != null && {rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - dir: { - pathKind: FilePathKind.Relative, - path: entryName, - basePath: baseDir, - getDirectoryName: () => entryName.replace(/\.mdx$/, ''), - getAbsolutePath: () => filePath - }, - ...seriesInfo.series != null && {series: seriesInfo.series}, - agentName: seriesInfo.agentName, - ...seriName != null && {seriName}, - rawMdxContent: rawContent - } - } -} diff --git a/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/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..e2d33f36 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, @@ -7,14 +7,14 @@ import type { SkillPrompt, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import * as fs from 'node:fs' import * as path from 'node:path' import {getPlatformFixedDir} from '@truenine/desk-paths' import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' import {AbstractOutputPlugin, filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' -import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' +import {FilePathKind, PLUGIN_NAMES} from '../plugin-shared' /** * Represents the filename of the project memory file. @@ -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-jetbrains-codestyle/JetBrainsIDECodeStyleConfigOutputPlugin.ts b/cli/src/plugins/plugin-jetbrains-codestyle/JetBrainsIDECodeStyleConfigOutputPlugin.ts index 1aa7e340..b9dcfc8c 100644 --- a/cli/src/plugins/plugin-jetbrains-codestyle/JetBrainsIDECodeStyleConfigOutputPlugin.ts +++ b/cli/src/plugins/plugin-jetbrains-codestyle/JetBrainsIDECodeStyleConfigOutputPlugin.ts @@ -3,10 +3,10 @@ import type { OutputWriteContext, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {FilePathKind, IDEKind} from '@truenine/plugin-shared' +import {FilePathKind, IDEKind} from '../plugin-shared' const IDEA_DIR = '.idea' const CODE_STYLES_DIR = 'codeStyles' diff --git a/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts b/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts index e9893253..241fedbb 100644 --- a/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-openai-codex-cli/CodexCLIOutputPlugin.ts @@ -1,22 +1,19 @@ import type { - FastCommandPrompt, + CommandPrompt, OutputPluginContext, OutputWriteContext, - SkillPrompt, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import * as path from 'node:path' -import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' import {AbstractOutputPlugin, filterCommandsByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' -import {PLUGIN_NAMES} from '@truenine/plugin-shared' +import {PLUGIN_NAMES} from '../plugin-shared' const PROJECT_MEMORY_FILE = 'AGENTS.md' const GLOBAL_CONFIG_DIR = '.codex' const PROMPTS_SUBDIR = 'prompts' const SKILLS_SUBDIR = 'skills' -const SKILL_FILE_NAME = 'SKILL.md' export class CodexCLIOutputPlugin extends AbstractOutputPlugin { constructor() { @@ -65,8 +62,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} = ctx.collectedInputContext + if (globalMemory != null || (commands?.length ?? 0) > 0) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } @@ -76,7 +73,7 @@ export class CodexCLIOutputPlugin extends AbstractOutputPlugin { } async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {globalMemory, fastCommands, skills} = ctx.collectedInputContext + const {globalMemory, commands} = ctx.collectedInputContext const projectConfig = this.resolvePromptSourceProjectConfig(ctx) const fileResults: WriteResult[] = [] const globalDir = this.getGlobalConfigDir() @@ -87,102 +84,25 @@ export class CodexCLIOutputPlugin extends AbstractOutputPlugin { fileResults.push(result) } - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) { - const result = await this.writeGlobalFastCommand(ctx, globalDir, cmd) - fileResults.push(result) - } - } - - if (skills == null || skills.length === 0) return {files: fileResults, dirs: []} + if (commands == null || commands.length === 0) return {files: fileResults, dirs: []} - const filteredSkills = filterSkillsByProjectConfig(skills, projectConfig) - for (const skill of filteredSkills) { - const skillResults = await this.writeGlobalSkill(ctx, globalDir, skill) - fileResults.push(...skillResults) + const filteredCommands = filterCommandsByProjectConfig(commands, projectConfig) + for (const cmd of filteredCommands) { + const result = await this.writeGlobalCommand(ctx, globalDir, cmd) + fileResults.push(result) } 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') } - - private async writeGlobalSkill( - ctx: OutputWriteContext, - globalDir: string, - skill: SkillPrompt - ): Promise { - const results: WriteResult[] = [] - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - const skillDir = path.join(globalDir, SKILLS_SUBDIR, skillName) - const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) - - const content = this.buildCodexSkillContent(skill) - const mainResult = await this.writeFile(ctx, skillFilePath, content, 'globalSkill') - results.push(mainResult) - - if (skill.childDocs != null) { - for (const refDoc of skill.childDocs) { - const fileName = refDoc.dir.path.replace(/\.mdx$/, '.md') - const fullPath = path.join(skillDir, fileName) - const refResult = await this.writeFile(ctx, fullPath, refDoc.content as string, 'skillRefDoc') - results.push(refResult) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) { - const fullPath = path.join(skillDir, resource.relativePath) - const resourceResult = await this.writeFile(ctx, fullPath, resource.content, 'skillResource') - results.push(resourceResult) - } - } - - return results - } - - private buildCodexSkillContent(skill: SkillPrompt): string { - const fm = skill.yamlFrontMatter - const name = this.normalizeSkillName(fm.name, 64) - const description = this.normalizeToSingleLine(fm.description, 1024) - - const metadata: Record = {} - if (fm.displayName != null) metadata['short-description'] = fm.displayName - if (fm.version != null) metadata['version'] = fm.version - if (fm.author != null) metadata['author'] = fm.author - if (fm.keywords != null && fm.keywords.length > 0) metadata['keywords'] = [...fm.keywords] - - const fmData: Record = {name, description} - if (Object.keys(metadata).length > 0) fmData['metadata'] = metadata - if (fm.allowTools != null && fm.allowTools.length > 0) fmData['allowed-tools'] = fm.allowTools.join(' ') - - return buildMarkdownWithFrontMatter(fmData, skill.content as string) - } - - private normalizeSkillName(name: string, maxLength: number): string { - let normalized = name - .toLowerCase() - .replaceAll(/[^a-z0-9-]/g, '-') - .replaceAll(/-+/g, '-') - .replaceAll(/^-+|-+$/g, '') - - if (normalized.length > maxLength) normalized = normalized.slice(0, maxLength).replace(/-+$/, '') - return normalized - } - - private normalizeToSingleLine(text: string, maxLength: number): string { - const singleLine = text.replaceAll(/[\r\n]+/g, ' ').replaceAll(/\s+/g, ' ').trim() - if (singleLine.length > maxLength) return `${singleLine.slice(0, maxLength - 3)}...` - return singleLine - } } diff --git a/cli/src/plugins/plugin-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 21b978dc..00000000 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.test.ts +++ /dev/null @@ -1,777 +0,0 @@ -import type {CollectedInputContext, FastCommandPrompt, OutputPluginContext, Project, RelativePath, RulePrompt, SkillPrompt, SubAgentPrompt} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {OpencodeCLIOutputPlugin} from './OpencodeCLIOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -class TestableOpencodeCLIOutputPlugin extends OpencodeCLIOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } - - public testBuildRuleFileName(rule: RulePrompt): string { - return (this as any).buildRuleFileName(rule) - } - - public testBuildRuleContent(rule: RulePrompt): string { - return (this as any).buildRuleContent(rule) - } -} - -function createMockRulePrompt(options: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { - const content = options.content ?? '# Rule body' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', ''), - markdownContents: [], - yamlFrontMatter: {description: 'ignored', globs: options.globs}, - series: options.series, - ruleName: options.ruleName, - globs: options.globs, - scope: options.scope ?? 'global' - } as RulePrompt -} - -describe('opencodeCLIOutputPlugin', () => { - let tempDir: string, - plugin: TestableOpencodeCLIOutputPlugin, - mockContext: OutputPluginContext - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-test-')) - plugin = new TestableOpencodeCLIOutputPlugin() - plugin.setMockHomeDir(tempDir) - - mockContext = { - collectedInputContext: { - workspace: { - projects: [], - directory: createMockRelativePath('.', tempDir) - }, - globalMemory: { - type: PromptKind.GlobalMemory, - content: 'Global Memory Content', - filePathKind: FilePathKind.Absolute, - dir: createMockRelativePath('.', tempDir), - markdownContents: [] - }, - fastCommands: [], - subAgents: [], - skills: [] - } as unknown as CollectedInputContext, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, - fs, - path, - glob: {} as any - } - }) - - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch { - } // ignore cleanup errors - } - }) - - describe('constructor', () => { - it('should have correct plugin name', () => expect(plugin.name).toBe('OpencodeCLIOutputPlugin')) - - it('should have correct dependencies', () => expect(plugin.dependsOn).toContain('AgentsOutputPlugin')) - }) - - describe('registerGlobalOutputDirs', () => { - it('should register commands, agents, and skills subdirectories in .config/opencode', async () => { - const dirs = await plugin.registerGlobalOutputDirs(mockContext) - - const dirPaths = dirs.map(d => d.path) - expect(dirPaths).toContain('commands') - expect(dirPaths).toContain('agents') - expect(dirPaths).toContain('skills') - - const expectedBasePath = path.join(tempDir, '.config/opencode') - dirs.forEach(d => expect(d.basePath).toBe(expectedBasePath)) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should register project cleanup directories', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - rootMemoryPrompt: { - type: PromptKind.ProjectRootMemory, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir) as any, - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase} - }, - childMemoryPrompts: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const dirs = await plugin.registerProjectOutputDirs(ctxWithProject) - const dirPaths = dirs.map(d => d.path) - - expect(dirPaths.some(p => p.includes(path.join('.config/opencode', 'commands')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.config/opencode', 'agents')))).toBe(true) - expect(dirPaths.some(p => p.includes(path.join('.config/opencode', 'skills')))).toBe(true) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register AGENTS.md in global config dir', async () => { - const files = await plugin.registerGlobalOutputFiles(mockContext) - const outputFile = files.find(f => f.path === 'AGENTS.md') - - expect(outputFile).toBeDefined() - expect(outputFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) - }) - - it('should register fast commands in commands subdirectory', async () => { - const mockCmd: FastCommandPrompt = { - type: PromptKind.FastCommand, - commandName: 'test-cmd', - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-cmd', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, description: 'desc'} - } - - const ctxWithCmd = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - fastCommands: [mockCmd] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithCmd) - const cmdFile = files.find(f => f.path.includes('test-cmd.md')) - - expect(cmdFile).toBeDefined() - expect(cmdFile?.path).toContain('commands') - expect(cmdFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) - }) - - it('should register agents in agents subdirectory', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('review-agent.md', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'review-agent', description: 'Code review agent'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('review-agent.md')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('agents') - expect(agentFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) - }) - - it('should strip .mdx suffix from agent path and use .md', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: 'agent content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('code-review.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'code-review', description: 'Code review agent'} - } - - const ctxWithAgent = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithAgent) - const agentFile = files.find(f => f.path.includes('agents')) - - expect(agentFile).toBeDefined() - expect(agentFile?.path).toContain('code-review.cn.md') - expect(agentFile?.path).not.toContain('.mdx') - }) - - it('should register skills in skills subdirectory', async () => { - const mockSkill: SkillPrompt = { - type: PromptKind.Skill, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-skill', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'test-skill', description: 'desc'} - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) - const skillFile = files.find(f => f.path.includes('SKILL.md')) - - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain('skills') - expect(skillFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should return empty array (no project-level AGENTS.md)', async () => { - const mockProject: Project = { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project-a', tempDir), - childMemoryPrompts: [] - } - - const ctxWithProject = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - workspace: { - ...mockContext.collectedInputContext.workspace, - projects: [mockProject] - } - } - } - - const files = await plugin.registerProjectOutputFiles(ctxWithProject) - expect(files).toEqual([]) - }) - }) - - describe('skill name normalization', () => { - it('should normalize skill names to opencode format', async () => { - const testCases = [ - {input: 'My Skill', expected: 'my-skill'}, - {input: 'Skill__Name', expected: 'skill-name'}, - {input: '-skill-', expected: 'skill'}, - {input: 'UPPER_CASE', expected: 'upper-case'}, - {input: 'tool.name', expected: 'tool-name'}, - {input: 'a'.repeat(70), expected: 'a'.repeat(64)} // truncated to 64 chars - ] - - for (const {input, expected} of testCases) { - const mockSkill: SkillPrompt = { - type: PromptKind.Skill, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath(input, tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: input, description: 'desc'} - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) - const skillFile = files.find(f => f.path.includes('SKILL.md')) - - expect(skillFile).toBeDefined() - expect(skillFile?.path).toContain(`skills/${expected}/`) - } - }) - }) - - describe('mcp config output', () => { - it('should register opencode.json when skill has mcp config', async () => { - const mockSkill: SkillPrompt = { - type: PromptKind.Skill, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-skill', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'test-skill', description: 'desc'}, - mcpConfig: { - type: PromptKind.SkillMcpConfig, - rawContent: '{}', - mcpServers: { - 'test-server': {command: 'test-cmd'} - } - } - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - const files = await plugin.registerGlobalOutputFiles(ctxWithSkill) - const configFile = files.find(f => f.path === 'opencode.json') - - expect(configFile).toBeDefined() - expect(configFile?.basePath).toBe(path.join(tempDir, '.config/opencode')) - }) - - it('should write correct local mcp config', async () => { - const mockSkill: SkillPrompt = { - type: PromptKind.Skill, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-skill', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'test-skill', description: 'desc'}, - mcpConfig: { - type: PromptKind.SkillMcpConfig, - rawContent: '{}', - mcpServers: { - 'local-server': { - command: 'node', - args: ['index.js'], - env: {KEY: 'value'} - } - } - } - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - await plugin.writeGlobalOutputs(ctxWithSkill) - - const configPath = path.join(tempDir, '.config/opencode/opencode.json') - expect(fs.existsSync(configPath)).toBe(true) - - const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) - expect(content.mcp).toBeDefined() - expect(content.mcp['local-server']).toBeDefined() - expect(content.mcp['local-server'].type).toBe('local') - expect(content.mcp['local-server'].command).toEqual(['node', 'index.js']) - expect(content.mcp['local-server'].environment).toEqual({KEY: 'value'}) - expect(content.mcp['local-server'].enabled).toBe(true) - }) - - it('should write correct remote mcp config', async () => { - const mockSkill: SkillPrompt = { - type: PromptKind.Skill, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-skill', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'test-skill', description: 'desc'}, - mcpConfig: { - type: PromptKind.SkillMcpConfig, - rawContent: '{}', - mcpServers: { - 'remote-server': { - url: 'https://example.com/mcp' - } as any - } - } - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - await plugin.writeGlobalOutputs(ctxWithSkill) - - const configPath = path.join(tempDir, '.config/opencode/opencode.json') - const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) - - expect(content.mcp['remote-server']).toBeDefined() - expect(content.mcp['remote-server'].type).toBe('remote') - expect(content.mcp['remote-server'].url).toBe('https://example.com/mcp') - expect(content.mcp['remote-server'].enabled).toBe(true) - }) - - it('should add opencode-rules@latest to plugin array when writing mcp config', async () => { - const mockSkill: SkillPrompt = { - type: PromptKind.Skill, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-skill', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'test-skill', description: 'desc'}, - mcpConfig: { - type: PromptKind.SkillMcpConfig, - rawContent: '{}', - mcpServers: { - 'local-server': { - command: 'node' - } - } - } - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - await plugin.writeGlobalOutputs(ctxWithSkill) - - const configPath = path.join(tempDir, '.config/opencode/opencode.json') - const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record - expect(Array.isArray(content.plugin)).toBe(true) - expect((content.plugin as unknown[])).toContain('opencode-rules@latest') - }) - - it('should preserve existing plugins and append opencode-rules@latest only once', async () => { - const opencodeDir = path.join(tempDir, '.config/opencode') - fs.mkdirSync(opencodeDir, {recursive: true}) - const configPath = path.join(opencodeDir, 'opencode.json') - fs.writeFileSync( - configPath, - JSON.stringify({plugin: ['existing-plugin', 'opencode-rules@latest']}, null, 2), - 'utf8' - ) - - const mockSkill: SkillPrompt = { - type: PromptKind.Skill, - content: 'content', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('test-skill', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'test-skill', description: 'desc'}, - mcpConfig: { - type: PromptKind.SkillMcpConfig, - rawContent: '{}', - mcpServers: { - 'local-server': { - command: 'node' - } - } - } - } - - const ctxWithSkill = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - skills: [mockSkill] - } - } - - await plugin.writeGlobalOutputs(ctxWithSkill) - - const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record - expect(content.plugin).toEqual(['existing-plugin', 'opencode-rules@latest']) - }) - }) - - describe('clean effect', () => { - it('should remove opencode-rules@latest from plugin array on clean', async () => { - const opencodeDir = path.join(tempDir, '.config/opencode') - fs.mkdirSync(opencodeDir, {recursive: true}) - const configPath = path.join(opencodeDir, 'opencode.json') - fs.writeFileSync( - configPath, - JSON.stringify({mcp: {some: {command: 'npx'}}, plugin: ['a', 'opencode-rules@latest', 'b']}, null, 2), - 'utf8' - ) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)} - }, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()}, - dryRun: false - } as any - - await plugin.onCleanComplete(ctx) - - const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record - expect(content.mcp).toEqual({}) - expect(content.plugin).toEqual(['a', 'b']) - }) - - it('should delete plugin field when opencode-rules@latest is the only plugin on clean', async () => { - const opencodeDir = path.join(tempDir, '.config/opencode') - fs.mkdirSync(opencodeDir, {recursive: true}) - const configPath = path.join(opencodeDir, 'opencode.json') - fs.writeFileSync( - configPath, - JSON.stringify({mcp: {some: {command: 'npx'}}, plugin: ['opencode-rules@latest']}, null, 2), - 'utf8' - ) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)} - }, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()}, - dryRun: false - } as any - - await plugin.onCleanComplete(ctx) - - const content = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record - expect(content.mcp).toEqual({}) - expect(content.plugin).toBeUndefined() - }) - }) - - describe('writeGlobalOutputs sub-agent mdx regression', () => { - it('should write sub agent file with .md extension when source has .mdx', async () => { - const mockAgent: SubAgentPrompt = { - type: PromptKind.SubAgent, - content: '# Code Review Agent', - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('reviewer.cn.mdx', tempDir), - markdownContents: [], - length: 0, - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, name: 'reviewer', description: 'Code review agent'} - } - - const writeCtx = { - ...mockContext, - collectedInputContext: { - ...mockContext.collectedInputContext, - subAgents: [mockAgent] - } - } - - const results = await plugin.writeGlobalOutputs(writeCtx) - const agentResult = results.files.find(f => f.path.path === 'reviewer.cn.md') - - expect(agentResult).toBeDefined() - expect(agentResult?.success).toBe(true) - - const writtenPath = path.join(tempDir, '.config/opencode', 'agents', 'reviewer.cn.md') - expect(fs.existsSync(writtenPath)).toBe(true) - expect(fs.existsSync(path.join(tempDir, '.config/opencode', 'agents', 'reviewer.cn.mdx'))).toBe(false) - expect(fs.existsSync(path.join(tempDir, '.config/opencode', 'agents', 'reviewer.cn.mdx.md'))).toBe(false) - }) - }) - - describe('buildRuleFileName', () => { - it('should produce rule-{series}-{ruleName}.md', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'naming', globs: []}) - expect(plugin.testBuildRuleFileName(rule)).toBe('rule-01-naming.md') - }) - }) - - describe('buildRuleContent', () => { - it('should return plain content when globs is empty', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: [], content: '# No globs'}) - expect(plugin.testBuildRuleContent(rule)).toBe('# No globs') - }) - - it('should use globs field (not paths) in YAML frontmatter per opencode-rules format', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts'], content: '# TS rule'}) - const content = plugin.testBuildRuleContent(rule) - expect(content).toContain('globs:') - expect(content).not.toMatch(/^paths:/m) - }) - - it('should output globs as YAML array items', () => { - const rule = createMockRulePrompt({series: '01', ruleName: 'ts', globs: ['**/*.ts', '**/*.tsx'], content: '# Body'}) - const content = plugin.testBuildRuleContent(rule) - expect(content).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 4a70d190..2ea7b502 100644 --- a/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-opencode-cli/OpencodeCLIOutputPlugin.ts @@ -1,9 +1,18 @@ -import type {FastCommandPrompt, McpServerConfig, OutputPluginContext, OutputWriteContext, RulePrompt, SkillPrompt, SubAgentPrompt, WriteResult, WriteResults} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +import type {CommandPrompt, OutputPluginContext, OutputWriteContext, RulePrompt, SkillPrompt, SubAgentPrompt, WriteResult, WriteResults} from '../plugin-shared' +import type {RelativePath} from '../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 {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' +import { + applySubSeriesGlobPrefix, + BaseCLIOutputPlugin, + filterCommandsByProjectConfig, + filterRulesByProjectConfig, + filterSkillsByProjectConfig, + filterSubAgentsByProjectConfig, + McpConfigManager, + transformMcpConfigForOpencode +} from '@truenine/plugin-output-shared' +import {FilePathKind, PLUGIN_NAMES} from '../plugin-shared' const GLOBAL_MEMORY_FILE = 'AGENTS.md' const GLOBAL_CONFIG_DIR = '.config/opencode' @@ -25,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] @@ -119,6 +128,106 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { }) } + override async registerProjectOutputFiles(ctx: OutputPluginContext): Promise { + const results: RelativePath[] = [] + const {projects} = ctx.collectedInputContext.workspace + + for (const project of projects) { + if (project.rootMemoryPrompt != null && project.dirFromWorkspacePath != null) { + 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)) + results.push(this.createRelativePath(filePath, project.dirFromWorkspacePath.basePath, () => RULES_SUBDIR)) + } + } + } + + return results.map(result => { + 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] @@ -143,16 +252,12 @@ export class OpencodeCLIOutputPlugin extends BaseCLIOutputPlugin { ctx: OutputWriteContext, skills: readonly SkillPrompt[] ): Promise { - const mergedMcpServers: Record = {} - - for (const skill of skills) { - if (skill.mcpConfig == null) continue - const {mcpServers} = skill.mcpConfig - for (const [mcpName, mcpConfig] of Object.entries(mcpServers)) mergedMcpServers[mcpName] = this.transformMcpConfigForOpencode(mcpConfig) - } + const manager = new McpConfigManager({fs, logger: this.log}) - 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 +269,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( @@ -269,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 @@ -381,11 +450,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]}) } @@ -398,41 +467,36 @@ 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'), - 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)) + 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)) + } } - 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'), + 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 - ) - 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)) + ) + 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 @@ -444,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 59f3572e..cebcf46e 100644 --- a/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts +++ b/cli/src/plugins/plugin-output-shared/AbstractOutputPlugin.ts @@ -1,7 +1,7 @@ -import type {CleanEffectHandler, EffectRegistration, EffectResult, FastCommandPrompt, ILogger, OutputCleanContext, OutputPlugin, OutputPluginContext, OutputWriteContext, Project, RegistryOperationResult, RulePrompt, RuleScope, WriteEffectHandler, WriteResult, WriteResults} from '@truenine/plugin-shared' -import type {FastCommandSeriesPluginOverride, Path, ProjectConfig, RegistryData, RelativePath} from '@truenine/plugin-shared/types' - import type {Buffer} from 'node:buffer' +import type {CleanEffectHandler, CommandPrompt, CommandSeriesPluginOverride, EffectRegistration, EffectResult, ILogger, OutputCleanContext, OutputPlugin, OutputPluginContext, OutputWriteContext, Project, RegistryOperationResult, RulePrompt, RuleScope, SkillPrompt, WriteEffectHandler, WriteResult, WriteResults} from '../plugin-shared' + +import type {Path, ProjectConfig, RegistryData, RelativePath} from '../plugin-shared/types' import type {RegistryWriter} from './registry/RegistryWriter' import * as fs from 'node:fs' import * as os from 'node:os' @@ -22,13 +22,53 @@ import { AbstractPlugin, FilePathKind, PluginKind -} from '@truenine/plugin-shared' +} from '../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 +} /** - * Options for transforming fast command names in output filenames. - * Used by transformFastCommandName method to control prefix handling. + * Context for error handling */ -export interface FastCommandNameTransformOptions { +export interface ErrorContext { + readonly action: string + readonly path?: string + readonly [key: string]: unknown +} + +/** + * Options for transforming command names in output filenames. + * Used by transformCommandName method to control prefix handling. + */ +export interface CommandNameTransformOptions { readonly includeSeriesPrefix?: boolean readonly seriesSeparator?: string } @@ -373,6 +413,7 @@ 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/BaseCLIOutputPlugin.ts b/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts index cfe8db8e..eacf9fe8 100644 --- a/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts +++ b/cli/src/plugins/plugin-output-shared/BaseCLIOutputPlugin.ts @@ -1,5 +1,5 @@ import type { - FastCommandPrompt, + CommandPrompt, OutputPluginContext, OutputWriteContext, RulePrompt, @@ -8,8 +8,8 @@ import type { SubAgentPrompt, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import type {AbstractOutputPluginOptions} from './AbstractOutputPlugin' import * as path from 'node:path' import {writeFileSync as deskWriteFileSync} from '@truenine/desk-paths' @@ -23,7 +23,7 @@ export interface BaseCLIOutputPluginOptions extends AbstractOutputPluginOptions readonly agentsSubDir?: string readonly skillsSubDir?: string - readonly supportsFastCommands?: boolean + readonly supportsCommands?: boolean readonly supportsSubAgents?: boolean @@ -36,7 +36,7 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { protected readonly commandsSubDir: string protected readonly agentsSubDir: string protected readonly skillsSubDir: string - protected readonly supportsFastCommands: boolean + protected readonly supportsCommands: boolean protected readonly supportsSubAgents: boolean protected readonly supportsSkills: boolean protected readonly toolPreset?: string @@ -46,24 +46,14 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { this.commandsSubDir = options.commandsSubDir ?? 'commands' this.agentsSubDir = options.agentsSubDir ?? 'agents' this.skillsSubDir = options.skillsSubDir ?? 'skills' - this.supportsFastCommands = options.supportsFastCommands ?? true + this.supportsCommands = options.supportsCommands ?? true this.supportsSubAgents = options.supportsSubAgents ?? true this.supportsSkills = options.supportsSkills ?? true if (options.toolPreset !== void 0) this.toolPreset = options.toolPreset } async registerGlobalOutputDirs(_ctx: OutputPluginContext): Promise { - const globalDir = this.getGlobalConfigDir() - const results: RelativePath[] = [] - const subdirs: string[] = [] - - if (this.supportsFastCommands) subdirs.push(this.commandsSubDir) - if (this.supportsSubAgents) subdirs.push(this.agentsSubDir) - if (this.supportsSkills) subdirs.push(this.skillsSubDir) - - for (const subdir of subdirs) results.push(this.createRelativePath(subdir, globalDir, () => subdir)) - - return results + return [] } async registerProjectOutputDirs(ctx: OutputPluginContext): Promise { @@ -71,21 +61,41 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { const {projects} = ctx.collectedInputContext.workspace const subdirs: string[] = [] // Subdirectories might be needed there too // Most CLI tools store project-local config in a hidden folder .toolname - if (this.supportsFastCommands) subdirs.push(this.commandsSubDir) + if (this.supportsCommands) subdirs.push(this.commandsSubDir) if (this.supportsSubAgents) subdirs.push(this.agentsSubDir) if (this.supportsSkills) subdirs.push(this.skillsSubDir) - if (subdirs.length === 0) return [] + this.log.debug('registerProjectOutputDirs', { + plugin: this.name, + projectCount: projects.length, + supportsCommands: this.supportsCommands, + supportsSubAgents: this.supportsSubAgents, + supportsSkills: this.supportsSkills, + subdirs, + commandsCount: ctx.collectedInputContext.commands?.length ?? 0, + subAgentsCount: ctx.collectedInputContext.subAgents?.length ?? 0, + skillsCount: ctx.collectedInputContext.skills?.length ?? 0 + }) + + if (subdirs.length === 0) { + this.log.debug('no subdirs to register', {plugin: this.name}) + return [] + } for (const project of projects) { - if (project.dirFromWorkspacePath == null) continue + if (project.dirFromWorkspacePath == null) { + this.log.debug('project has no dirFromWorkspacePath', {plugin: this.name, projectName: project.name}) + continue + } for (const subdir of subdirs) { const dirPath = path.join(project.dirFromWorkspacePath.path, this.globalConfigDir, subdir) // Assuming globalConfigDir is something like .claude results.push(this.createRelativePath(dirPath, project.dirFromWorkspacePath.basePath, () => subdir)) + this.log.debug('registered output dir', {plugin: this.name, project: project.name, subdir, dirPath}) } } + this.log.debug('registerProjectOutputDirs complete', {plugin: this.name, dirCount: results.length}) return results } @@ -93,7 +103,27 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { const results: RelativePath[] = [] const {projects} = ctx.collectedInputContext.workspace + this.log.debug('registerProjectOutputFiles start', { + plugin: this.name, + projectCount: projects.length, + commandsAvailable: ctx.collectedInputContext.commands != null, + commandsCount: ctx.collectedInputContext.commands?.length ?? 0, + subAgentsAvailable: ctx.collectedInputContext.subAgents != null, + subAgentsCount: ctx.collectedInputContext.subAgents?.length ?? 0, + skillsAvailable: ctx.collectedInputContext.skills != null, + skillsCount: ctx.collectedInputContext.skills?.length ?? 0 + }) + for (const project of projects) { + this.log.debug('processing project', { + plugin: this.name, + projectName: project.name, + hasRootMemory: project.rootMemoryPrompt != null, + childMemoryCount: project.childMemoryPrompts?.length ?? 0, + hasDirFromWorkspace: project.dirFromWorkspacePath != null, + projectConfig: project.projectConfig + }) + if (project.rootMemoryPrompt != null && project.dirFromWorkspacePath != null) { // Root memory file results.push(this.createFileRelativePath(project.dirFromWorkspacePath, this.outputFileName)) } @@ -103,79 +133,142 @@ 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 (this.supportsFastCommands && fastCommands != null) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) { - const fileName = this.transformFastCommandName(cmd, transformOptions) - results.push(this.createRelativePath(path.join(this.commandsSubDir, fileName), globalDir, () => this.commandsSubDir)) + if (project.dirFromWorkspacePath == null) { + this.log.debug('project has no dirFromWorkspacePath, skipping', {plugin: this.name, projectName: project.name}) + continue } - } - 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)) + const {projectConfig} = project + const basePath = path.join(project.dirFromWorkspacePath.path, this.globalConfigDir) + const transformOptions = {includeSeriesPrefix: true} as const + + if (this.supportsCommands && ctx.collectedInputContext.commands != null) { + const allCommands = ctx.collectedInputContext.commands + const filteredCommands = filterCommandsByProjectConfig(allCommands, projectConfig) + this.log.debug('filtering commands', { + plugin: this.name, + projectName: project.name, + totalCommands: allCommands.length, + filteredCommands: filteredCommands.length, + 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)) + this.log.debug('registered command file', {plugin: this.name, project: project.name, fileName}) + } + } else { + this.log.debug('commands skipped', { + plugin: this.name, + supportsCommands: this.supportsCommands, + hasCommands: ctx.collectedInputContext.commands != null + }) } - } - - 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 allSubAgents = ctx.collectedInputContext.subAgents + const filteredSubAgents = filterSubAgentsByProjectConfig(allSubAgents, projectConfig) + this.log.debug('filtering subAgents', { + plugin: this.name, + projectName: project.name, + totalSubAgents: allSubAgents.length, + filteredSubAgents: filteredSubAgents.length, + 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)) + this.log.debug('registered agent file', {plugin: this.name, project: project.name, fileName}) } + } else { + this.log.debug('subAgents skipped', { + plugin: this.name, + supportsSubAgents: this.supportsSubAgents, + hasSubAgents: ctx.collectedInputContext.subAgents != null + }) } - 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 allSkills = ctx.collectedInputContext.skills + const filteredSkills = filterSkillsByProjectConfig(allSkills, projectConfig) + this.log.debug('filtering skills', { + plugin: this.name, + projectName: project.name, + totalSkills: allSkills.length, + filteredSkills: filteredSkills.length + }) + 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)) + } + } } + } else { + this.log.debug('skills skipped', { + plugin: this.name, + supportsSkills: this.supportsSkills, + hasSkills: ctx.collectedInputContext.skills != null + }) } } + + this.log.debug('registerProjectOutputFiles complete', {plugin: this.name, fileCount: results.length}) 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 {workspace, globalMemory, commands, subAgents, skills} = ctx.collectedInputContext const hasProjectOutputs = workspace.projects.some( p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 ) const hasGlobalMemory = globalMemory != null - const hasFastCommands = this.supportsFastCommands && (fastCommands?.length ?? 0) > 0 - const hasSubAgents = this.supportsSubAgents && (subAgents?.length ?? 0) > 0 - const hasSkills = this.supportsSkills && (skills?.length ?? 0) > 0 - - if (hasProjectOutputs || hasGlobalMemory || hasFastCommands || hasSubAgents || hasSkills) return true + const hasProjectLevelCommands = this.supportsCommands && (commands?.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 + + this.log.debug('canWrite check', { + plugin: this.name, + hasProjectOutputs, + hasGlobalMemory, + hasProjectLevelCommands, + hasProjectLevelSubAgents, + hasProjectLevelSkills, + projectCount: workspace.projects.length, + commandsCount: commands?.length ?? 0, + subAgentsCount: subAgents?.length ?? 0, + skillsCount: skills?.length ?? 0, + supportsCommands: this.supportsCommands, + supportsSubAgents: this.supportsSubAgents, + supportsSkills: this.supportsSkills + }) + + if (hasProjectOutputs || hasGlobalMemory || hasProjectLevelCommands || hasProjectLevelSubAgents || hasProjectLevelSkills) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false @@ -186,11 +279,29 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { const fileResults: WriteResult[] = [] const dirResults: WriteResult[] = [] + this.log.debug('writeProjectOutputs start', { + plugin: this.name, + projectCount: projects.length, + commandsCount: ctx.collectedInputContext.commands?.length ?? 0, + subAgentsCount: ctx.collectedInputContext.subAgents?.length ?? 0, + skillsCount: ctx.collectedInputContext.skills?.length ?? 0 + }) + for (const project of projects) { const projectName = project.name ?? 'unknown' const projectDir = project.dirFromWorkspacePath - if (projectDir == null) continue + this.log.debug('writing project outputs', { + plugin: this.name, + projectName, + hasProjectDir: projectDir != null, + projectConfig: project.projectConfig + }) + + if (projectDir == null) { + this.log.debug('project has no dirFromWorkspacePath, skipping', {plugin: this.name, projectName}) + continue + } if (project.rootMemoryPrompt != null) { const result = await this.writePromptFile(ctx, projectDir, project.rootMemoryPrompt.content as string, `project:${projectName}/root`) @@ -203,6 +314,77 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { fileResults.push(childResult) } } + + const {projectConfig} = project + const basePath = path.join(projectDir.basePath, projectDir.path, this.globalConfigDir) + + if (this.supportsCommands && ctx.collectedInputContext.commands != null) { + const allCommands = ctx.collectedInputContext.commands + const filteredCommands = filterCommandsByProjectConfig(allCommands, projectConfig) + this.log.debug('writing commands', { + plugin: this.name, + projectName, + totalCommands: allCommands.length, + filteredCommands: filteredCommands.length, + projectConfig + }) + for (const cmd of filteredCommands) { + const cmdResults = await this.writeCommand(ctx, basePath, cmd) + fileResults.push(...cmdResults) + this.log.debug('wrote command', {plugin: this.name, projectName, commandName: cmd.commandName, success: cmdResults.every(r => r.success)}) + } + } else { + this.log.debug('commands not written', { + plugin: this.name, + supportsCommands: this.supportsCommands, + hasCommands: ctx.collectedInputContext.commands != null + }) + } + + if (this.supportsSubAgents && ctx.collectedInputContext.subAgents != null) { + const allSubAgents = ctx.collectedInputContext.subAgents + const filteredSubAgents = filterSubAgentsByProjectConfig(allSubAgents, projectConfig) + this.log.debug('writing subAgents', { + plugin: this.name, + projectName, + totalSubAgents: allSubAgents.length, + filteredSubAgents: filteredSubAgents.length, + projectConfig + }) + for (const agent of filteredSubAgents) { + const agentResults = await this.writeSubAgent(ctx, basePath, agent) + fileResults.push(...agentResults) + this.log.debug('wrote subAgent', {plugin: this.name, projectName, agentPath: agent.dir.path, success: agentResults.every(r => r.success)}) + } + } else { + this.log.debug('subAgents not written', { + plugin: this.name, + supportsSubAgents: this.supportsSubAgents, + hasSubAgents: ctx.collectedInputContext.subAgents != null + }) + } + + if (this.supportsSkills && ctx.collectedInputContext.skills != null) { + const allSkills = ctx.collectedInputContext.skills + const filteredSkills = filterSkillsByProjectConfig(allSkills, projectConfig) + this.log.debug('writing skills', { + plugin: this.name, + projectName, + totalSkills: allSkills.length, + filteredSkills: filteredSkills.length + }) + for (const skill of filteredSkills) { + const skillResults = await this.writeSkill(ctx, basePath, skill) + fileResults.push(...skillResults) + this.log.debug('wrote skill', {plugin: this.name, projectName, skillName: skill.yamlFrontMatter?.name, success: skillResults.every(r => r.success)}) + } + } else { + this.log.debug('skills not written', { + plugin: this.name, + supportsSkills: this.supportsSkills, + hasSkills: ctx.collectedInputContext.skills != null + }) + } } return {files: fileResults, dirs: dirResults} @@ -213,78 +395,42 @@ 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} } - protected async writeFastCommand( + protected 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) @@ -293,7 +439,7 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { let useRecompiledFrontMatter = false if (cmd.rawMdxContent != null && this.toolPreset != null) { // Only recompile if we have raw content AND a tool preset is configured - this.log.debug('recompiling fast command with tool preset', { + this.log.debug('recompiling command with tool preset', { file: cmd.dir.getAbsolutePath(), toolPreset: this.toolPreset, hasRawContent: true @@ -308,7 +454,7 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { useRecompiledFrontMatter = true } catch (e) { - this.log.warn('failed to recompile fast command, using default', { + this.log.warn('failed to recompile command, using default', { file: cmd.dir.getAbsolutePath(), error: e instanceof Error ? e.message : String(e) }) @@ -319,7 +465,7 @@ export abstract class BaseCLIOutputPlugin extends AbstractOutputPlugin { ? this.buildMarkdownContent(compiledContent, compiledFrontMatter) : this.buildMarkdownContentWithRaw(compiledContent, compiledFrontMatter, cmd.rawFrontMatter) - return [await this.writeFile(ctx, fullPath, content, 'fastCommand')] + return [await this.writeFile(ctx, fullPath, content, 'command')] } protected 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..35d73a3c --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/McpConfigManager.ts @@ -0,0 +1,210 @@ +import type {ILogger, McpServerConfig, SkillPrompt} from '../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/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 a93e9de3..fce9d52c 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 + CommandNameTransformOptions, + ErrorContext, + RuleContentOptions, + SkillFrontMatterOptions, + WriteOperationOptions } from './AbstractOutputPlugin' export { BaseCLIOutputPlugin @@ -12,11 +16,35 @@ export { export type { BaseCLIOutputPluginOptions } from './BaseCLIOutputPlugin' +export { + FileExtensions, + FrontMatterFields, + GlobalConfigDirs, + IgnoreFiles, + OutputFileNames, + OutputPrefixes, + OutputSubdirectories, + PreservedSkills, + ToolPresets +} from './constants' +export { + McpConfigManager, + transformMcpConfigForCursor, + transformMcpConfigForOpencode +} from './McpConfigManager' +export type { + McpConfigFormat, + McpConfigTransformer, + McpServerEntry, + McpWriteResult, + TransformedMcpConfig +} from './McpConfigManager' export { applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig, + filterSubAgentsByProjectConfig, findAllGitRepos, findGitModuleInfoDirs, matchesSeries, diff --git a/cli/src/plugins/plugin-output-shared/registry/RegistryWriter.ts b/cli/src/plugins/plugin-output-shared/registry/RegistryWriter.ts index 3721cba1..247cd67b 100644 --- a/cli/src/plugins/plugin-output-shared/registry/RegistryWriter.ts +++ b/cli/src/plugins/plugin-output-shared/registry/RegistryWriter.ts @@ -7,14 +7,14 @@ * @see Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 7.1, 7.2 */ -import type {ILogger} from '@truenine/plugin-shared' -import type {RegistryData, RegistryOperationResult} from '@truenine/plugin-shared/types' +import type {ILogger} from '../../plugin-shared' +import type {RegistryData, RegistryOperationResult} from '../../plugin-shared/types' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import {createLogger} from '@truenine/plugin-shared' +import {createLogger} from '../../plugin-shared' /** * Abstract base class for registry configuration writers. 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..02818c1e --- /dev/null +++ b/cli/src/plugins/plugin-output-shared/utils/filters.ts @@ -0,0 +1,71 @@ +import type {CommandPrompt, RulePrompt, SeriName, SkillPrompt, SubAgentPrompt} from '../../plugin-shared' +import type {ProjectConfig} from '../../plugin-shared/types' +import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' + +/** + * Interface for items that can be filtered by series name + */ +export interface SeriesFilterable { + readonly seriName?: SeriName +} + +/** + * 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 commands by project configuration + * @deprecated Use filterByProjectConfig(commands, config, 'commands') instead + */ +export function filterCommandsByProjectConfig( + commands: readonly CommandPrompt[], + projectConfig: ProjectConfig | undefined +): readonly CommandPrompt[] { + 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/pathNormalization.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/pathNormalization.property.test.ts deleted file mode 100644 index 514700d3..00000000 --- a/cli/src/plugins/plugin-output-shared/utils/pathNormalization.property.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** Property 4: SubSeries path normalization idempotence. Validates: Requirement 5.4 */ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -import {normalizeSubdirPath} from './ruleFilter' - -const pathArb = fc.stringMatching(/^[./_a-z0-9-]{0,40}$/) - -const subdirPathArb = fc.oneof( - fc.constant('./foo/'), - fc.constant('foo/'), - fc.constant('./foo'), - fc.constant('foo'), - fc.constant(''), - fc.constant('./'), - fc.constant('.//foo//'), - fc.constant('././foo///'), - fc.constant('./a/b/c/'), - pathArb -) - -describe('property 4: subSeries path normalization idempotence', () => { - it('normalize(normalize(p)) === normalize(p) for arbitrary path strings', () => { // **Validates: Requirement 5.4** - fc.assert( - fc.property(subdirPathArb, p => { - const once = normalizeSubdirPath(p) - const twice = normalizeSubdirPath(once) - expect(twice).toBe(once) - }), - {numRuns: 200} - ) - }) - - it('result never starts with ./', () => { // **Validates: Requirement 5.4** - fc.assert( - fc.property(subdirPathArb, p => { - const result = normalizeSubdirPath(p) - expect(result.startsWith('./')).toBe(false) - }), - {numRuns: 200} - ) - }) - - it('result never ends with /', () => { // **Validates: Requirement 5.4** - fc.assert( - fc.property(subdirPathArb, p => { - const result = normalizeSubdirPath(p) - expect(result.endsWith('/')).toBe(false) - }), - {numRuns: 200} - ) - }) - - it('empty string stays empty', () => { // **Validates: Requirement 5.4** - expect(normalizeSubdirPath('')).toBe('') - }) -}) diff --git a/cli/src/plugins/plugin-output-shared/utils/ruleFilter.ts b/cli/src/plugins/plugin-output-shared/utils/ruleFilter.ts index 117e2ea0..259f2109 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 type {RulePrompt} from '../../plugin-shared' +import type {Project, ProjectConfig} from '../../plugin-shared/types' +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/seriesFilter.napi-equivalence.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/seriesFilter.napi-equivalence.property.test.ts deleted file mode 100644 index 72df74a6..00000000 --- a/cli/src/plugins/plugin-output-shared/utils/seriesFilter.napi-equivalence.property.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** Property 5: NAPI and TypeScript behavioral equivalence. Validates: Requirement 6.4 */ -import * as napiConfig from '@truenine/config' -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -const napiAvailable = typeof napiConfig.matchesSeries === 'function' - && typeof napiConfig.resolveEffectiveIncludeSeries === 'function' - && typeof napiConfig.resolveSubSeries === 'function' - -function resolveEffectiveIncludeSeriesTS(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] { - if (topLevel == null && typeSpecific == null) return [] - return [...new Set([...topLevel ?? [], ...typeSpecific ?? []])] -} - -function matchesSeriesTS(seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]): boolean { - if (seriName == null) return true - if (effectiveIncludeSeries.length === 0) return true - if (typeof seriName === 'string') return effectiveIncludeSeries.includes(seriName) - return seriName.some(name => effectiveIncludeSeries.includes(name)) -} - -function resolveSubSeriesTS( - topLevel?: Readonly>, - typeSpecific?: Readonly> -): Record { - if (topLevel == null && typeSpecific == null) return {} - const merged: Record = {} - for (const [key, values] of Object.entries(topLevel ?? {})) merged[key] = [...values] - for (const [key, values] of Object.entries(typeSpecific ?? {})) { - merged[key] = Object.hasOwn(merged, key) ? [...new Set([...merged[key]!, ...values])] : [...values] - } - return merged -} - -const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) - -const optionalSeriesArb = fc.option(fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), {nil: void 0}) - -const seriNameArb: fc.Arbitrary = fc.oneof( - fc.constant(null), - fc.constant(void 0), - seriesNameArb, - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) -) - -const subSeriesRecordArb = fc.option( - fc.dictionary(seriesNameArb, fc.array(seriesNameArb, {minLength: 0, maxLength: 5})), - {nil: void 0} -) - -function sortedArray(arr: readonly string[]): string[] { - return [...arr].sort() -} - -function sortedRecord(rec: Readonly>): Record { - const out: Record = {} - for (const key of Object.keys(rec).sort()) out[key] = [...new Set(rec[key])].sort() - return out -} - -describe.skipIf(!napiAvailable)('property 5: NAPI and TypeScript behavioral equivalence', () => { - it('resolveEffectiveIncludeSeries: NAPI and TS produce same set', () => { // **Validates: Requirement 6.4** - fc.assert( - fc.property( - optionalSeriesArb, - optionalSeriesArb, - (topLevel, typeSpecific) => { - const napiResult = napiConfig.resolveEffectiveIncludeSeries(topLevel, typeSpecific) - const tsResult = resolveEffectiveIncludeSeriesTS(topLevel, typeSpecific) - expect(sortedArray(napiResult)).toEqual(sortedArray(tsResult)) - } - ), - {numRuns: 200} - ) - }) - - it('matchesSeries: NAPI and TS produce identical boolean', () => { // **Validates: Requirement 6.4** - fc.assert( - fc.property( - seriNameArb, - fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), - (seriName, list) => { - const napiResult = napiConfig.matchesSeries(seriName, list) - const tsResult = matchesSeriesTS(seriName, list) - expect(napiResult).toBe(tsResult) - } - ), - {numRuns: 200} - ) - }) - - it('resolveSubSeries: NAPI and TS produce same merged record', () => { // **Validates: Requirement 6.4** - fc.assert( - fc.property( - subSeriesRecordArb, - subSeriesRecordArb, - (topLevel, typeSpecific) => { - const napiResult = napiConfig.resolveSubSeries(topLevel, typeSpecific) - const tsResult = resolveSubSeriesTS(topLevel, typeSpecific) - expect(sortedRecord(napiResult)).toEqual(sortedRecord(tsResult)) - } - ), - {numRuns: 200} - ) - }) -}) diff --git a/cli/src/plugins/plugin-output-shared/utils/seriesFilter.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/seriesFilter.property.test.ts deleted file mode 100644 index 57fe292d..00000000 --- a/cli/src/plugins/plugin-output-shared/utils/seriesFilter.property.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -import {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' - -/** Property 1: Effective IncludeSeries is the set union. Validates: Requirements 3.1, 3.2, 3.3, 3.4 */ -describe('resolveEffectiveIncludeSeries property tests', () => { - const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s)) - - const optionalSeriesArb = fc.option(fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), {nil: void 0}) - - it('property 1: result is the set union of both inputs, undefined treated as empty', () => { // **Validates: Requirements 3.1, 3.2, 3.3, 3.4** - fc.assert( - fc.property( - optionalSeriesArb, - optionalSeriesArb, - (topLevel, typeSpecific) => { - const result = resolveEffectiveIncludeSeries(topLevel, typeSpecific) - const expectedUnion = new Set([...topLevel ?? [], ...typeSpecific ?? []]) - - for (const item of result) expect(expectedUnion.has(item)).toBe(true) // every result element comes from an input - for (const item of expectedUnion) expect(result).toContain(item) // every input element is in the result - expect(result.length).toBe(new Set(result).size) // no duplicates - } - ), - {numRuns: 200} - ) - }) - - it('property 1: both undefined yields empty array', () => { // **Validates: Requirement 3.4** - const result = resolveEffectiveIncludeSeries(void 0, void 0) - expect(result).toEqual([]) - }) - - it('property 1: only top-level defined yields top-level (deduplicated)', () => { // **Validates: Requirement 3.2** - fc.assert( - fc.property( - fc.array(seriesNameArb, {minLength: 1, maxLength: 10}), - topLevel => { - const result = resolveEffectiveIncludeSeries(topLevel, void 0) - const expected = [...new Set(topLevel)] - expect(result).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) - - it('property 1: only type-specific defined yields type-specific (deduplicated)', () => { // **Validates: Requirement 3.3** - fc.assert( - fc.property( - fc.array(seriesNameArb, {minLength: 1, maxLength: 10}), - typeSpecific => { - const result = resolveEffectiveIncludeSeries(void 0, typeSpecific) - const expected = [...new Set(typeSpecific)] - expect(result).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) -}) - -/** Property 2: Series matching correctness. Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5 */ -describe('matchesSeries property tests', () => { - const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s)) - - const nonEmptySeriesListArb = fc.array(seriesNameArb, {minLength: 1, maxLength: 10}) - .map(arr => [...new Set(arr)]) - .filter(arr => arr.length > 0) - - const seriNameArb: fc.Arbitrary = fc.oneof( - fc.constant(null), - fc.constant(void 0), - seriesNameArb, - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) - ) - - it('property 2: null/undefined seriName is always included regardless of list', () => { // **Validates: Requirements 4.1** - fc.assert( - fc.property( - fc.oneof(fc.constant(null), fc.constant(void 0)), - nonEmptySeriesListArb, - (seriName, list) => { expect(matchesSeries(seriName, list)).toBe(true) } - ), - {numRuns: 200} - ) - }) - - it('property 2: empty effectiveIncludeSeries includes all seriName values', () => { // **Validates: Requirements 4.4** - fc.assert( - fc.property( - seriNameArb, - seriName => { expect(matchesSeries(seriName, [])).toBe(true) } - ), - {numRuns: 200} - ) - }) - - it('property 2: string seriName included iff it is a member of the list', () => { // **Validates: Requirements 4.2, 4.5** - fc.assert( - fc.property( - seriesNameArb, - nonEmptySeriesListArb, - (seriName, list) => { - const result = matchesSeries(seriName, list) - const expected = list.includes(seriName) - expect(result).toBe(expected) - } - ), - {numRuns: 200} - ) - }) - - it('property 2: array seriName included iff intersection with list is non-empty', () => { // **Validates: Requirements 4.3** - fc.assert( - fc.property( - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}), - nonEmptySeriesListArb, - (seriNameArr, list) => { - const result = matchesSeries(seriNameArr, list) - const hasIntersection = seriNameArr.some(n => list.includes(n)) - expect(result).toBe(hasIntersection) - } - ), - {numRuns: 200} - ) - }) - - it('property 2: combined — all seriName variants obey spec rules', () => { // **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5** - fc.assert( - fc.property( - seriNameArb, - fc.oneof(fc.constant([] as string[]), nonEmptySeriesListArb), - (seriName, list) => { - const result = matchesSeries(seriName, list) - - if (seriName == null) { - expect(result).toBe(true) // 4.1 - } else if (list.length === 0) { - expect(result).toBe(true) // 4.4 - } else if (typeof seriName === 'string') { - expect(result).toBe(list.includes(seriName)) // 4.2, 4.5 - } else { - expect(result).toBe(seriName.some(n => list.includes(n))) // 4.3 - } - } - ), - {numRuns: 300} - ) - }) -}) diff --git a/cli/src/plugins/plugin-output-shared/utils/seriesFilter.ts b/cli/src/plugins/plugin-output-shared/utils/seriesFilter.ts index d82f4922..5aec1d04 100644 --- a/cli/src/plugins/plugin-output-shared/utils/seriesFilter.ts +++ b/cli/src/plugins/plugin-output-shared/utils/seriesFilter.ts @@ -1,5 +1,6 @@ -/** Core series filtering helpers. Delegates to Rust NAPI via `@truenine/config` when available, falls back to pure-TS implementations otherwise. */ +/** Core series filtering helpers. Delegates to the unified CLI Rust NAPI when available, falls back to pure-TS implementations otherwise. */ import {createRequire} from 'node:module' +import process from 'node:process' function resolveEffectiveIncludeSeriesTS(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] { if (topLevel == null && typeSpecific == null) return [] @@ -32,13 +33,46 @@ interface SeriesFilterFns { resolveSubSeries: typeof resolveSubSeriesTS } +function isSeriesFilterFns(candidate: unknown): candidate is SeriesFilterFns { + if (candidate == null || typeof candidate !== 'object') return false + const c = candidate as Record + return typeof c['matchesSeries'] === 'function' + && typeof c['resolveEffectiveIncludeSeries'] === 'function' + && typeof c['resolveSubSeries'] === 'function' +} + function tryLoadNapi(): SeriesFilterFns | undefined { + const suffixMap: Record = { + 'win32-x64': 'win32-x64-msvc', + 'linux-x64': 'linux-x64-gnu', + 'linux-arm64': 'linux-arm64-gnu', + 'darwin-arm64': 'darwin-arm64', + 'darwin-x64': 'darwin-x64' + } + const suffix = suffixMap[`${process.platform}-${process.arch}`] + if (suffix == null) return void 0 + + const packageName = `@truenine/memory-sync-cli-${suffix}` + const binaryFile = `napi-memory-sync-cli.${suffix}.node` + try { const _require = createRequire(import.meta.url) - const napi = _require('@truenine/config') as SeriesFilterFns - if (typeof napi.matchesSeries === 'function' - && typeof napi.resolveEffectiveIncludeSeries === 'function' - && typeof napi.resolveSubSeries === 'function') return napi + const candidates = [ + packageName, + `${packageName}/${binaryFile}`, + `./${binaryFile}` + ] + + for (const specifier of candidates) { + try { + const loaded = _require(specifier) as unknown + const possible = [loaded, (loaded as {default?: unknown})?.default, (loaded as {config?: unknown})?.config] + for (const candidate of possible) { + if (isSeriesFilterFns(candidate)) return candidate + } + } + catch {} + } } catch { /* NAPI unavailable — pure-TS fallback will be used */ } return void 0 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/subSeriesGlobExpansion.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/subSeriesGlobExpansion.property.test.ts deleted file mode 100644 index a9fadb1d..00000000 --- a/cli/src/plugins/plugin-output-shared/utils/subSeriesGlobExpansion.property.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** Property 3: SubSeries glob expansion. Validates: Requirements 5.1, 5.2, 5.3 */ -import type {RulePrompt} from '@truenine/plugin-shared' -import type {ProjectConfig} from '@truenine/plugin-shared/types' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -import {applySubSeriesGlobPrefix} from './ruleFilter' - -const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) - -const seriNameArb: fc.Arbitrary = fc.oneof( - fc.constant(null), - fc.constant(void 0), - seriesNameArb, - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) -) - -const globGen = fc.stringMatching(/^\*\*\/\*\.[a-z]{1,5}$/) -const globArrayGen = fc.array(globGen, {minLength: 1, maxLength: 5}) -const subdirGen = fc.stringMatching(/^[a-z][a-z0-9/-]{0,30}$/) - .filter(s => !s.endsWith('/') && !s.includes('//')) - -function createMockRulePrompt(seriName: string | string[] | null | undefined, globs: readonly string[] = ['**/*.ts']): RulePrompt { - const content = '# Rule body' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: {pathKind: FilePathKind.Relative, path: '.', basePath: '', getDirectoryName: () => '.', getAbsolutePath: () => '.'}, - markdownContents: [], - yamlFrontMatter: {description: 'Test rule', globs: [...globs]}, - series: 'test', - ruleName: 'test-rule', - globs: [...globs], - scope: 'project', - seriName - } as unknown as RulePrompt -} - -describe('property 3: subSeries glob expansion', () => { - it('rules without seriName have unchanged globs', () => { // **Validates: Requirements 5.2** - fc.assert( - fc.property( - globArrayGen, - subdirGen, - fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), - (globs, subdir, seriNames) => { - const rule = createMockRulePrompt(null, globs) - const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - expect(result[0]!.globs).toEqual(globs) - } - ), - {numRuns: 200} - ) - }) - - it('rules with undefined seriName have unchanged globs', () => { // **Validates: Requirements 5.2** - fc.assert( - fc.property( - globArrayGen, - subdirGen, - fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), - (globs, subdir, seriNames) => { - const rule = createMockRulePrompt(void 0, globs) - const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - expect(result[0]!.globs).toEqual(globs) - } - ), - {numRuns: 200} - ) - }) - - it('string seriName matching subSeries expands globs with subdir prefix', () => { // **Validates: Requirements 5.1** - fc.assert( - fc.property( - seriesNameArb, - globArrayGen, - subdirGen, - (seriName, globs, subdir) => { - const rule = createMockRulePrompt(seriName, globs) - const config: ProjectConfig = {subSeries: {[subdir]: [seriName]}} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - const resultGlobs = result[0]!.globs - for (const g of resultGlobs) expect(g).toContain(subdir) // every expanded glob contains the subdir prefix - } - ), - {numRuns: 200} - ) - }) - - it('array seriName matching subSeries expands globs for all matching subdirs', () => { // **Validates: Requirements 5.1, 5.3** - fc.assert( - fc.property( - seriesNameArb, - globArrayGen, - fc.array(subdirGen, {minLength: 2, maxLength: 4}).filter(arr => new Set(arr).size === arr.length), - (seriName, globs, subdirs) => { - const rule = createMockRulePrompt([seriName], globs) - const subSeries: Record = {} // each subdir maps to the same seriName - for (const sd of subdirs) subSeries[sd] = [seriName] - const config: ProjectConfig = {subSeries} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - const resultGlobs = result[0]!.globs - for (const sd of subdirs) expect(resultGlobs.some(g => g.includes(sd))).toBe(true) // every subdir appears in at least one expanded glob - } - ), - {numRuns: 200} - ) - }) - - it('non-matching seriName leaves globs unchanged', () => { // **Validates: Requirements 5.2** - fc.assert( - fc.property( - seriesNameArb, - seriesNameArb, - globArrayGen, - subdirGen, - (ruleSeriName, subSeriesSeriName, globs, subdir) => { - fc.pre(ruleSeriName !== subSeriesSeriName) - const rule = createMockRulePrompt(ruleSeriName, globs) - const config: ProjectConfig = {subSeries: {[subdir]: [subSeriesSeriName]}} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - expect(result[0]!.globs).toEqual(globs) - } - ), - {numRuns: 200} - ) - }) - - it('rule count is preserved', () => { // **Validates: Requirements 5.1, 5.2, 5.3** - fc.assert( - fc.property( - fc.array(fc.tuple(seriNameArb, globArrayGen), {minLength: 0, maxLength: 10}), - subdirGen, - fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), - (ruleSpecs, subdir, seriNames) => { - const rules = ruleSpecs.map(([sn, gl]) => createMockRulePrompt(sn, gl)) - const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} - const result = applySubSeriesGlobPrefix(rules, config) - expect(result).toHaveLength(rules.length) - } - ), - {numRuns: 200} - ) - }) - - it('deterministic: same input produces same output', () => { // **Validates: Requirements 5.1, 5.2, 5.3** - fc.assert( - fc.property( - seriNameArb, - globArrayGen, - subdirGen, - fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), - (seriName, globs, subdir, seriNames) => { - const rules = [createMockRulePrompt(seriName, globs)] - const config: ProjectConfig = {subSeries: {[subdir]: seriNames}} - const result1 = applySubSeriesGlobPrefix(rules, config) - const result2 = applySubSeriesGlobPrefix(rules, config) - expect(result1).toEqual(result2) - } - ), - {numRuns: 200} - ) - }) - - it('at least one glob per matched subdir when matched', () => { // **Validates: Requirements 5.1, 5.3** - fc.assert( - fc.property( - seriesNameArb, - globArrayGen, - fc.array(subdirGen, {minLength: 1, maxLength: 4}).filter(arr => new Set(arr).size === arr.length), - (seriName, globs, subdirs) => { - const rule = createMockRulePrompt(seriName, globs) - const subSeries: Record = {} - for (const sd of subdirs) subSeries[sd] = [seriName] - const config: ProjectConfig = {subSeries} - const result = applySubSeriesGlobPrefix([rule], config) - expect(result).toHaveLength(1) - const resultGlobs = result[0]!.globs - expect(resultGlobs.length).toBeGreaterThanOrEqual(subdirs.length) // at least as many globs as unique matched subdirs - } - ), - {numRuns: 200} - ) - }) -}) diff --git a/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts b/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts deleted file mode 100644 index 08bf5b0f..00000000 --- a/cli/src/plugins/plugin-output-shared/utils/typeSpecificFilters.property.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** Property 6: Type-specific filters use correct config sections. Validates: Requirements 7.1, 7.2, 7.3, 7.4 */ -import type {FastCommandPrompt, RulePrompt, SkillPrompt, SubAgentPrompt} from '../../../plugin-shared' -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 {matchesSeries, resolveEffectiveIncludeSeries} from './seriesFilter' -import {filterSkillsByProjectConfig} from './skillFilter' -import {filterSubAgentsByProjectConfig} from './subAgentFilter' - -const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) - -const seriNameArb: fc.Arbitrary = fc.oneof( - fc.constant(null), - fc.constant(void 0), - seriesNameArb, - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}) -) - -const optionalSeriesArb = fc.option(fc.array(seriesNameArb, {minLength: 0, maxLength: 10}), {nil: void 0}) - -const typeSeriesConfigArb = fc.record({includeSeries: optionalSeriesArb}) - -const projectConfigArb: fc.Arbitrary = fc.record({ - includeSeries: optionalSeriesArb, - rules: fc.option(typeSeriesConfigArb, {nil: void 0}), - skills: fc.option(typeSeriesConfigArb, {nil: void 0}), - subAgents: fc.option(typeSeriesConfigArb, {nil: void 0}), - commands: fc.option(typeSeriesConfigArb, {nil: void 0}) -}) - -function makeSkill(seriName: string | string[] | null | undefined): SkillPrompt { - return {seriName} as unknown as SkillPrompt -} - -function makeRule(seriName: string | string[] | null | undefined): RulePrompt { - return {seriName, globs: [], scope: 'project', series: '', ruleName: '', type: 'Rule'} as unknown as RulePrompt -} - -function makeSubAgent(seriName: string | string[] | null | undefined): SubAgentPrompt { - return {seriName, agentName: '', type: 'SubAgent'} as unknown as SubAgentPrompt -} - -function makeCommand(seriName: string | string[] | null | undefined): FastCommandPrompt { - return {seriName, commandName: '', type: 'FastCommand'} as unknown as FastCommandPrompt -} - -describe('property 6: type-specific filters use correct config sections', () => { - it('filterSkillsByProjectConfig matches manual filtering with skills includeSeries', () => { // **Validates: Requirement 7.1** - fc.assert( - fc.property( - projectConfigArb, - fc.array(seriNameArb, {minLength: 0, maxLength: 10}), - (config, seriNames) => { - const skills = seriNames.map(makeSkill) - const filtered = filterSkillsByProjectConfig(skills, config) - const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.skills?.includeSeries) - const expected = skills.filter(s => matchesSeries(s.seriName, effectiveSeries)) - expect(filtered).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) - - it('filterRulesByProjectConfig matches manual filtering with rules includeSeries', () => { // **Validates: Requirement 7.2** - fc.assert( - fc.property( - projectConfigArb, - fc.array(seriNameArb, {minLength: 0, maxLength: 10}), - (config, seriNames) => { - const rules = seriNames.map(makeRule) - const filtered = filterRulesByProjectConfig(rules, config) - const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.rules?.includeSeries) - const expected = rules.filter(r => matchesSeries(r.seriName, effectiveSeries)) - expect(filtered).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) - - it('filterSubAgentsByProjectConfig matches manual filtering with subAgents includeSeries', () => { // **Validates: Requirement 7.3** - fc.assert( - fc.property( - projectConfigArb, - fc.array(seriNameArb, {minLength: 0, maxLength: 10}), - (config, seriNames) => { - const subAgents = seriNames.map(makeSubAgent) - const filtered = filterSubAgentsByProjectConfig(subAgents, config) - const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.subAgents?.includeSeries) - const expected = subAgents.filter(sa => matchesSeries(sa.seriName, effectiveSeries)) - expect(filtered).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) - - it('filterCommandsByProjectConfig matches manual filtering with commands includeSeries', () => { // **Validates: Requirement 7.4** - fc.assert( - fc.property( - projectConfigArb, - fc.array(seriNameArb, {minLength: 0, maxLength: 10}), - (config, seriNames) => { - const commands = seriNames.map(makeCommand) - const filtered = filterCommandsByProjectConfig(commands, config) - const effectiveSeries = resolveEffectiveIncludeSeries(config.includeSeries, config.commands?.includeSeries) - const expected = commands.filter(c => matchesSeries(c.seriName, effectiveSeries)) - expect(filtered).toEqual(expected) - } - ), - {numRuns: 200} - ) - }) -}) diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.frontmatter.test.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.frontmatter.test.ts deleted file mode 100644 index 06b4ed2e..00000000 --- a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.frontmatter.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import {beforeEach, describe, expect, it} from 'vitest' -import {QoderIDEPluginOutputPlugin} from './QoderIDEPluginOutputPlugin' - -describe('qoderidepluginoutputplugin front matter', () => { - let plugin: QoderIDEPluginOutputPlugin - - beforeEach(() => plugin = new QoderIDEPluginOutputPlugin()) - - describe('buildAlwaysRuleContent', () => { - it('should include type: user_command in front matter', () => { - const content = 'Test always rule content' - const result = (plugin as any).buildAlwaysRuleContent(content) - - expect(result).toContain('type: user_command') - expect(result).toContain('trigger: always_on') - expect(result).toContain(content) - }) - }) - - describe('buildGlobRuleContent', () => { - it('should include type: user_command in front matter', () => { - const mockChild = { - content: 'Test glob rule content', - workingChildDirectoryPath: {path: 'src/utils'} - } - - const result = (plugin as any).buildGlobRuleContent(mockChild) - - expect(result).toContain('type: user_command') - expect(result).toContain('trigger: glob') - expect(result).toContain('glob: src/utils/**') - expect(result).toContain('Test glob rule content') - }) - }) - - describe('buildFastCommandFrontMatter', () => { - it('should include type: user_command in fast command front matter', () => { - const mockCmd = { - yamlFrontMatter: { - description: 'Test fast command', - argumentHint: 'test args', - allowTools: ['tool1', 'tool2'] - } - } - - const result = (plugin as any).buildFastCommandFrontMatter(mockCmd) - - expect(result.type).toBe('user_command') - expect(result.description).toBe('Test fast command') - expect(result.argumentHint).toBe('test args') - expect(result.allowTools).toEqual(['tool1', 'tool2']) - }) - - it('should handle fast command without yamlFrontMatter', () => { - const mockCmd = {} - - const result = (plugin as any).buildFastCommandFrontMatter(mockCmd) - - expect(result.type).toBe('user_command') - expect(result.description).toBe('Fast command') - }) - }) -}) diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts deleted file mode 100644 index 569b278c..00000000 --- a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.projectConfig.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type {OutputWriteContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {createMockProject, createMockRulePrompt} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {QoderIDEPluginOutputPlugin} from './QoderIDEPluginOutputPlugin' - -class TestableQoderIDEPlugin extends QoderIDEPluginOutputPlugin { - private mockHomeDir: string | null = null - public setMockHomeDir(dir: string | null): void { this.mockHomeDir = dir } - protected override getHomeDir(): string { return this.mockHomeDir ?? super.getHomeDir() } -} - -function createMockWriteContext(tempDir: string, rules: unknown[], projects: unknown[]): OutputWriteContext { - return { - collectedInputContext: { - workspace: { - projects: projects as never, - directory: {pathKind: 1, path: tempDir, basePath: tempDir, getDirectoryName: () => 'workspace', getAbsolutePath: () => tempDir} - }, - ideConfigFiles: [], - rules: rules as never, - fastCommands: [], - skills: [], - globalMemory: void 0, - aiAgentIgnoreConfigFiles: [] - }, - dryRun: false, - logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as never, - fs, - path, - glob: vi.fn() as never - } -} - -describe('qoderIDEPluginOutputPlugin - projectConfig filtering', () => { - let tempDir: string, plugin: TestableQoderIDEPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-proj-config-test-')) - plugin = new TestableQoderIDEPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch {} - }) - - function ruleFile(projectPath: string, series: string, ruleName: string): string { - return path.join(tempDir, projectPath, '.qoder', 'rules', `rule-${series}-${ruleName}.md`) - } - - describe('writeProjectOutputs', () => { - it('should write all project rules when no projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [createMockProject('proj1', tempDir, 'proj1')])) - - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(true) - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(true) - }) - - it('should only write rules matching include filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ])) - - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(true) - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(false) - }) - - it('should not write rules not matching includeSeries filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) - ])) - - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(false) - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(true) - }) - - it('should write rules without seriName regardless of include filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', void 0, 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ])) - - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule1'))).toBe(true) - expect(fs.existsSync(ruleFile('proj1', 'test', 'rule2'))).toBe(false) - }) - - it('should write expanded glob when subSeries matches seriName', async () => { - const rules = [createMockRulePrompt('test', 'rule1', 'uniapp', 'project')] - await plugin.writeProjectOutputs(createMockWriteContext(tempDir, rules, [ - createMockProject('proj1', tempDir, 'proj1', {rules: {subSeries: {applet: ['uniapp']}}}) - ])) - - const content = fs.readFileSync(ruleFile('proj1', 'test', 'rule1'), 'utf8') - expect(content).toContain('applet/') - }) - }) -}) diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.test.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.test.ts deleted file mode 100644 index 511e9751..00000000 --- a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -import type { - CollectedInputContext, - FastCommandPrompt, - GlobalMemoryPrompt, - OutputPluginContext, - OutputWriteContext, - ProjectChildrenMemoryPrompt, - ProjectRootMemoryPrompt, - RelativePath, - SkillPrompt -} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as path from 'node:path' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {QoderIDEPluginOutputPlugin} from './QoderIDEPluginOutputPlugin' - -vi.mock('node:fs') - -const MOCK_WORKSPACE_DIR = '/workspace/test' - -class TestableQoderIDEPluginOutputPlugin extends QoderIDEPluginOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => path.basename(pathStr), - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -function createMockRootMemoryPrompt(content: string, basePath: string): ProjectRootMemoryPrompt { - return { - type: PromptKind.ProjectRootMemory, - content, - dir: createMockRelativePath('.', basePath), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative - } as ProjectRootMemoryPrompt -} - -function createMockChildMemoryPrompt( - content: string, - projectPath: string, - basePath: string, - workingPath?: string -): ProjectChildrenMemoryPrompt { - const childPath = workingPath ?? projectPath - return { - type: PromptKind.ProjectChildrenMemory, - dir: createMockRelativePath(projectPath, basePath), - workingChildDirectoryPath: createMockRelativePath(childPath, basePath), - content, - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative - } as ProjectChildrenMemoryPrompt -} - -function createMockGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt { - return { - type: PromptKind.GlobalMemory, - content, - dir: createMockRelativePath('.', basePath), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative, - parentDirectoryPath: { - type: 'UserHome', - directory: createMockRelativePath('.qoder', basePath) - } - } as GlobalMemoryPrompt -} - -function createMockFastCommandPrompt( - commandName: string, - series?: string -): FastCommandPrompt { - const content = 'Run something' - return { - type: PromptKind.FastCommand, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - markdownContents: [], - yamlFrontMatter: { - description: 'Fast command' - }, - ...series != null && {series}, - commandName - } as FastCommandPrompt -} - -function createMockSkillPrompt( - name: string, - description: string, - content: string, - options?: { - mcpConfig?: {rawContent: string, mcpServers: Record} - childDocs?: {relativePath: string, content: string}[] - resources?: {relativePath: string, content: string, encoding: 'text' | 'base64'}[] - } -): SkillPrompt { - return { - type: PromptKind.Skill, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath(name, MOCK_WORKSPACE_DIR), - markdownContents: [], - yamlFrontMatter: { - name, - description - }, - mcpConfig: options?.mcpConfig != null - ? { - type: PromptKind.SkillMcpConfig, - rawContent: options.mcpConfig.rawContent, - mcpServers: options.mcpConfig.mcpServers - } - : void 0, - childDocs: options?.childDocs, - resources: options?.resources - } as SkillPrompt -} - -function createMockOutputPluginContext( - collectedInputContext: Partial -): OutputPluginContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [] - }, - ideConfigFiles: [], - ...collectedInputContext - } as CollectedInputContext - } -} - -function createMockOutputWriteContext( - collectedInputContext: Partial, - dryRun = false -): OutputWriteContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [] - }, - ideConfigFiles: [], - ...collectedInputContext - } as CollectedInputContext, - dryRun - } -} - -describe('qoder IDE plugin output plugin', () => { - let plugin: TestableQoderIDEPluginOutputPlugin - - beforeEach(() => { - plugin = new TestableQoderIDEPluginOutputPlugin() - plugin.setMockHomeDir('/home/test') - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.mkdirSync).mockReturnValue(void 0) - vi.mocked(fs.writeFileSync).mockReturnValue(void 0) - }) - - afterEach(() => vi.clearAllMocks()) - - describe('registerProjectOutputDirs', () => { - it('should register .qoder/rules for each project', async () => { - const ctx = createMockOutputPluginContext({ - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [ - {dirFromWorkspacePath: createMockRelativePath('project-a', MOCK_WORKSPACE_DIR)}, - {dirFromWorkspacePath: createMockRelativePath('project-b', MOCK_WORKSPACE_DIR)} - ] - } - }) - - const results = await plugin.registerProjectOutputDirs(ctx) - - expect(results).toHaveLength(2) - expect(results[0].path).toBe(path.join('project-a', '.qoder', 'rules')) - expect(results[1].path).toBe(path.join('project-b', '.qoder', 'rules')) - }) - }) - - describe('registerProjectOutputFiles', () => { - it('should register global.md, always.md, and child glob rules', async () => { - const projectDir = createMockRelativePath('project-a', MOCK_WORKSPACE_DIR) - const ctx = createMockOutputPluginContext({ - globalMemory: createMockGlobalMemoryPrompt('Global rules', MOCK_WORKSPACE_DIR), - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [ - { - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('Root rules', MOCK_WORKSPACE_DIR), - childMemoryPrompts: [ - createMockChildMemoryPrompt('Child rules', 'project-a/src', MOCK_WORKSPACE_DIR, 'src') - ] - } - ] - } - }) - - const results = await plugin.registerProjectOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('project-a', '.qoder', 'rules', 'global.md')) - expect(paths).toContain(path.join('project-a', '.qoder', 'rules', 'always.md')) - expect(paths).toContain(path.join('project-a', '.qoder', 'rules', 'glob-src.md')) - }) - }) - - describe('registerGlobalOutputDirs', () => { - it('should return empty when no fast commands exist', async () => { - const ctx = createMockOutputPluginContext({}) - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(0) - }) - - it('should register ~/.qoder/commands when fast commands exist', async () => { - const ctx = createMockOutputPluginContext({ - fastCommands: [createMockFastCommandPrompt('compile')] - }) - - const results = await plugin.registerGlobalOutputDirs(ctx) - - expect(results).toHaveLength(1) - expect(results[0].basePath).toBe(path.join('/home/test', '.qoder')) - expect(results[0].path).toBe('commands') - }) - - it('should register ~/.qoder/skills/ for each skill', async () => { - const ctx = createMockOutputPluginContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content'), - createMockSkillPrompt('another-skill', 'Another skill', 'More content') - ] - }) - - const results = await plugin.registerGlobalOutputDirs(ctx) - - expect(results).toHaveLength(2) - expect(results[0].path).toBe(path.join('skills', 'my-skill')) - expect(results[1].path).toBe(path.join('skills', 'another-skill')) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should register fast command files under ~/.qoder/commands', async () => { - const ctx = createMockOutputPluginContext({ - fastCommands: [ - createMockFastCommandPrompt('compile', 'build'), - createMockFastCommandPrompt('test') - ] - }) - - const results = await plugin.registerGlobalOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('commands', 'build-compile.md')) - expect(paths).toContain(path.join('commands', 'test.md')) - }) - - it('should register skill files under ~/.qoder/skills', async () => { - const ctx = createMockOutputPluginContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content') - ] - }) - - const results = await plugin.registerGlobalOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('skills', 'my-skill', 'SKILL.md')) - }) - - it('should register mcp.json when skill has MCP config', async () => { - const ctx = createMockOutputPluginContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content', { - mcpConfig: { - rawContent: '{"mcpServers": {}}', - mcpServers: {} - } - }) - ] - }) - - const results = await plugin.registerGlobalOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('skills', 'my-skill', 'SKILL.md')) - expect(paths).toContain(path.join('skills', 'my-skill', 'mcp.json')) - }) - - it('should register child docs and resources', async () => { - const ctx = createMockOutputPluginContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content', { - childDocs: [{relativePath: 'docs/guide.mdx', content: 'Guide content'}], - resources: [{relativePath: 'assets/image.png', content: 'base64data', encoding: 'base64'}] - }) - ] - }) - - const results = await plugin.registerGlobalOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('skills', 'my-skill', 'SKILL.md')) - expect(paths).toContain(path.join('skills', 'my-skill', 'docs', 'guide.md')) - expect(paths).toContain(path.join('skills', 'my-skill', 'assets', 'image.png')) - }) - }) - - describe('canWrite', () => { - it('should return true when project prompts exist', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [ - { - dirFromWorkspacePath: createMockRelativePath('project-a', MOCK_WORKSPACE_DIR), - rootMemoryPrompt: createMockRootMemoryPrompt('Root rules', MOCK_WORKSPACE_DIR) - } - ] - } - }) - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return true when skills exist', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [] - }, - skills: [createMockSkillPrompt('my-skill', 'A test skill', 'Skill content')] - }) - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return false when nothing to write', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [] - } - }) - - const result = await plugin.canWrite(ctx) - expect(result).toBe(false) - }) - }) - - describe('writeProjectOutputs', () => { - it('should write global, root, and child rule files with front matter', async () => { - const projectDir = createMockRelativePath('project-a', MOCK_WORKSPACE_DIR) - const ctx = createMockOutputWriteContext({ - globalMemory: createMockGlobalMemoryPrompt('Global rules', MOCK_WORKSPACE_DIR), - workspace: { - directory: createMockRelativePath('.', MOCK_WORKSPACE_DIR), - projects: [ - { - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('Root rules', MOCK_WORKSPACE_DIR), - childMemoryPrompts: [ - createMockChildMemoryPrompt('Child rules', 'project-a/src', MOCK_WORKSPACE_DIR, 'src') - ] - } - ] - } - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(3) - - const [calls] = [vi.mocked(fs.writeFileSync).mock.calls] - expect(calls).toHaveLength(3) - - const globalCall = calls.find(call => String(call[0]).includes(path.join('project-a', '.qoder', 'rules', 'global.md'))) - const rootCall = calls.find(call => String(call[0]).includes(path.join('project-a', '.qoder', 'rules', 'always.md'))) - const childCall = calls.find(call => String(call[0]).includes(path.join('project-a', '.qoder', 'rules', 'glob-src.md'))) - - expect(globalCall).toBeDefined() - expect(rootCall).toBeDefined() - expect(childCall).toBeDefined() - - expect(String(globalCall?.[1])).toContain('trigger: always_on') - expect(String(globalCall?.[1])).toContain('Global rules') - - expect(String(rootCall?.[1])).toContain('trigger: always_on') - expect(String(rootCall?.[1])).toContain('Root rules') - - expect(String(childCall?.[1])).toContain('trigger: glob') - expect(String(childCall?.[1])).toContain('glob: src/**') - expect(String(childCall?.[1])).toContain('Child rules') - }) - }) - - describe('writeGlobalOutputs', () => { - it('should write fast command files with front matter', async () => { - const ctx = createMockOutputWriteContext({ - fastCommands: [ - createMockFastCommandPrompt('compile', 'build') - ] - }) - - const results = await plugin.writeGlobalOutputs(ctx) - - expect(results.files).toHaveLength(1) - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] - expect(String(writeCall?.[0])).toContain(path.join('.qoder', 'commands', 'build-compile.md')) - expect(String(writeCall?.[1])).toContain('description: Fast command') - expect(String(writeCall?.[1])).toContain('Run something') - }) - - it('should write skill files to ~/.qoder/skills/', async () => { - const ctx = createMockOutputWriteContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content') - ] - }) - - const results = await plugin.writeGlobalOutputs(ctx) - - expect(results.files).toHaveLength(1) - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] - expect(String(writeCall?.[0])).toContain(path.join('.qoder', 'skills', 'my-skill', 'SKILL.md')) - expect(String(writeCall?.[1])).toContain('name: my-skill') - expect(String(writeCall?.[1])).toContain('description: A test skill') - expect(String(writeCall?.[1])).toContain('Skill content') - }) - - it('should write mcp.json when skill has MCP config', async () => { - const ctx = createMockOutputWriteContext({ - skills: [ - createMockSkillPrompt('my-skill', 'A test skill', 'Skill content', { - mcpConfig: { - rawContent: '{"mcpServers": {"test-server": {}}}', - mcpServers: {'test-server': {}} - } - }) - ] - }) - - const results = await plugin.writeGlobalOutputs(ctx) - - expect(results.files).toHaveLength(2) - - const writeCalls = vi.mocked(fs.writeFileSync).mock.calls - const mcpCall = writeCalls.find(call => String(call[0]).includes('mcp.json')) - expect(mcpCall).toBeDefined() - expect(String(mcpCall?.[1])).toContain('mcpServers') - }) - }) -}) diff --git a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts index e7183c22..3d05b5fc 100644 --- a/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts +++ b/cli/src/plugins/plugin-qoder-ide/QoderIDEPluginOutputPlugin.ts @@ -1,14 +1,15 @@ import type { - FastCommandPrompt, + CommandPrompt, OutputPluginContext, OutputWriteContext, ProjectChildrenMemoryPrompt, RulePrompt, + RuleScope, SkillPrompt, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import {Buffer} from 'node:buffer' import * as path from 'node:path' import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' @@ -78,12 +79,12 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { const globalDir = this.getGlobalConfigDir() - const {fastCommands, skills, rules} = ctx.collectedInputContext + const {commands, skills, rules} = ctx.collectedInputContext const projectConfig = this.resolvePromptSourceProjectConfig(ctx) const results: RelativePath[] = [] - 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) results.push(this.createRelativePath(COMMANDS_SUBDIR, globalDir, () => COMMANDS_SUBDIR)) } @@ -112,15 +113,15 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { const globalDir = this.getGlobalConfigDir() - const {fastCommands, skills, rules} = ctx.collectedInputContext + const {commands, skills, rules} = ctx.collectedInputContext const projectConfig = this.resolvePromptSourceProjectConfig(ctx) const results: RelativePath[] = [] const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + if (commands != null && commands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(commands, projectConfig) for (const cmd of filteredCommands) { - const fileName = this.transformFastCommandName(cmd, transformOptions) + const fileName = this.transformCommandName(cmd, transformOptions) results.push(this.createRelativePath( path.join(COMMANDS_SUBDIR, fileName), globalDir, @@ -184,13 +185,13 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {workspace, globalMemory, fastCommands, skills, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + const {workspace, globalMemory, commands, skills, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasProjectPrompts = workspace.projects.some( p => p.rootMemoryPrompt != null || (p.childMemoryPrompts?.length ?? 0) > 0 ) const hasRules = (rules?.length ?? 0) > 0 const hasQoderIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.qoderignore') ?? false - if (hasProjectPrompts || globalMemory != null || (fastCommands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0 || hasRules || hasQoderIgnore) return true + if (hasProjectPrompts || globalMemory != null || (commands?.length ?? 0) > 0 || (skills?.length ?? 0) > 0 || hasRules || hasQoderIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } @@ -243,7 +244,7 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { } async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {fastCommands, skills, rules} = ctx.collectedInputContext + const {commands, skills, rules} = ctx.collectedInputContext const projectConfig = this.resolvePromptSourceProjectConfig(ctx) const fileResults: WriteResult[] = [] const globalDir = this.getGlobalConfigDir() @@ -251,9 +252,9 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { const skillsDir = path.join(globalDir, SKILLS_SUBDIR) const rulesDir = path.join(globalDir, RULES_SUBDIR) - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) - for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalFastCommand(ctx, commandsDir, cmd)) + if (commands != null && commands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(commands, projectConfig) + for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalCommand(ctx, commandsDir, cmd)) } if (rules != null && rules.length > 0) { @@ -313,15 +314,15 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { return this.writeFile(ctx, fullPath, content, label) } - private async writeGlobalFastCommand( + private async writeGlobalCommand( ctx: OutputWriteContext, commandsDir: string, - cmd: FastCommandPrompt + 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 fmData = this.buildFastCommandFrontMatter(cmd) + const fmData = this.buildCommandFrontMatter(cmd) const content = buildMarkdownWithFrontMatter(fmData, cmd.content) return this.writeFile(ctx, fullPath, content, 'globalFastCommand') } @@ -381,7 +382,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, @@ -395,7 +396,7 @@ export class QoderIDEPluginOutputPlugin extends AbstractOutputPlugin { } } - private buildFastCommandFrontMatter(cmd: FastCommandPrompt): Record { + private buildCommandFrontMatter(cmd: CommandPrompt): Record { const fm = cmd.yamlFrontMatter if (fm == null) return {description: 'Fast command', type: 'user_command'} return { @@ -406,11 +407,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(', ') : '**/*', @@ -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-readme/ReadmeMdConfigFileOutputPlugin.property.test.ts b/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.property.test.ts deleted file mode 100644 index ed73f513..00000000 --- a/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.property.test.ts +++ /dev/null @@ -1,499 +0,0 @@ -import type { - CollectedInputContext, - OutputPluginContext, - OutputWriteContext, - ReadmeFileKind, - ReadmePrompt, - RelativePath, - Workspace -} from '@truenine/plugin-shared' - -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {createLogger, FilePathKind, PromptKind, README_FILE_KIND_MAP} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {afterEach, beforeEach, describe, expect, it} from 'vitest' -import {ReadmeMdConfigFileOutputPlugin} from './ReadmeMdConfigFileOutputPlugin' - -/** - * Feature: readme-md-plugin - * Property-based tests for ReadmeMdConfigFileOutputPlugin - */ -describe('readmeMdConfigFileOutputPlugin property tests', () => { - const plugin = new ReadmeMdConfigFileOutputPlugin() - let tempDir: string - - const allFileKinds = Object.keys(README_FILE_KIND_MAP) as ReadmeFileKind[] - - beforeEach(() => tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'readme-output-test-'))) - - afterEach(() => fs.rmSync(tempDir, {recursive: true, force: true})) - - function createReadmePrompt( - projectName: string, - content: string, - isRoot: boolean, - basePath: string, - subdir?: string, - fileKind: ReadmeFileKind = 'Readme' - ): ReadmePrompt { - const targetPath = isRoot ? projectName : path.join(projectName, subdir ?? '') - - const targetDir: RelativePath = { - pathKind: FilePathKind.Relative, - path: targetPath, - basePath, - getDirectoryName: () => isRoot ? projectName : path.basename(subdir ?? ''), - getAbsolutePath: () => path.resolve(basePath, targetPath) - } - - const dir: RelativePath = { - pathKind: FilePathKind.Relative, - path: targetPath, - basePath, - getDirectoryName: () => isRoot ? projectName : path.basename(subdir ?? ''), - getAbsolutePath: () => path.resolve(basePath, targetPath) - } - - return { - type: PromptKind.Readme, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - projectName, - targetDir, - isRoot, - fileKind, - markdownContents: [], - dir - } - } - - function createMockPluginContext( - readmePrompts: readonly ReadmePrompt[], - basePath: string - ): OutputPluginContext { - const workspace: Workspace = { - directory: { - pathKind: FilePathKind.Absolute, - path: basePath, - getDirectoryName: () => path.basename(basePath), - getAbsolutePath: () => basePath - }, - projects: [] - } - - const collectedInputContext: CollectedInputContext = { - workspace, - ideConfigFiles: [], - readmePrompts - } - - return { - collectedInputContext, - logger: createLogger('test', 'error'), - fs, - path, - glob: {} as typeof import('fast-glob') - } - } - - function createMockWriteContext( - readmePrompts: readonly ReadmePrompt[], - basePath: string, - dryRun: boolean = false - ): OutputWriteContext { - const pluginCtx = createMockPluginContext(readmePrompts, basePath) - return { - ...pluginCtx, - dryRun - } - } - - describe('property 3: Output Path Mapping', () => { - const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) - .filter(s => s.trim().length > 0) - - const fileKindArb = fc.constantFrom(...allFileKinds) - - it('should register correct output paths for root READMEs', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readme = createReadmePrompt(projectName, content, true, tempDir) - const ctx = createMockPluginContext([readme], tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths.length).toBe(1) - expect(registeredPaths[0].path).toBe(path.join(projectName, 'README.md')) - expect(registeredPaths[0].basePath).toBe(tempDir) - expect(registeredPaths[0].getAbsolutePath()).toBe( - path.join(tempDir, projectName, 'README.md') - ) - } - ), - {numRuns: 100} - ) - }) - - it('should register correct output paths for child READMEs', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - subdirNameArb, - readmeContentArb, - async (projectName, subdir, content) => { - const readme = createReadmePrompt(projectName, content, false, tempDir, subdir) - const ctx = createMockPluginContext([readme], tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths.length).toBe(1) - expect(registeredPaths[0].path).toBe(path.join(projectName, subdir, 'README.md')) - expect(registeredPaths[0].basePath).toBe(tempDir) - expect(registeredPaths[0].getAbsolutePath()).toBe( - path.join(tempDir, projectName, subdir, 'README.md') - ) - } - ), - {numRuns: 100} - ) - }) - - it('should write root README to correct path', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readme = createReadmePrompt(projectName, content, true, tempDir) - const ctx = createMockWriteContext([readme], tempDir, false) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files.length).toBe(1) - expect(results.files[0].success).toBe(true) - - const expectedPath = path.join(tempDir, projectName, 'README.md') - expect(fs.existsSync(expectedPath)).toBe(true) - expect(fs.readFileSync(expectedPath, 'utf8')).toBe(content) - } - ), - {numRuns: 100} - ) - }) - - it('should write child README to correct path', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - subdirNameArb, - readmeContentArb, - async (projectName, subdir, content) => { - const readme = createReadmePrompt(projectName, content, false, tempDir, subdir) - const ctx = createMockWriteContext([readme], tempDir, false) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files.length).toBe(1) - expect(results.files[0].success).toBe(true) - - const expectedPath = path.join(tempDir, projectName, subdir, 'README.md') - expect(fs.existsSync(expectedPath)).toBe(true) - expect(fs.readFileSync(expectedPath, 'utf8')).toBe(content) - } - ), - {numRuns: 100} - ) - }) - - it('should register correct output path per fileKind', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - fileKindArb, - async (projectName, content, fileKind) => { - const readme = createReadmePrompt(projectName, content, true, tempDir, void 0, fileKind) - const ctx = createMockPluginContext([readme], tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - const expectedFileName = README_FILE_KIND_MAP[fileKind].out - - expect(registeredPaths.length).toBe(1) - expect(registeredPaths[0].path).toBe(path.join(projectName, expectedFileName)) - } - ), - {numRuns: 100} - ) - }) - - it('should write correct output file per fileKind', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - fileKindArb, - async (projectName, content, fileKind) => { - const readme = createReadmePrompt(projectName, content, true, tempDir, void 0, fileKind) - const ctx = createMockWriteContext([readme], tempDir, false) - - const results = await plugin.writeProjectOutputs(ctx) - const expectedFileName = README_FILE_KIND_MAP[fileKind].out - - expect(results.files.length).toBe(1) - expect(results.files[0].success).toBe(true) - - const expectedPath = path.join(tempDir, projectName, expectedFileName) - expect(fs.existsSync(expectedPath)).toBe(true) - expect(fs.readFileSync(expectedPath, 'utf8')).toBe(content) - } - ), - {numRuns: 100} - ) - }) - - it('should write all three file kinds to separate files in same project', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readmes = allFileKinds.map(kind => - createReadmePrompt(projectName, `${content}-${kind}`, true, tempDir, void 0, kind)) - const ctx = createMockWriteContext(readmes, tempDir, false) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files.length).toBe(3) - expect(results.files.every(r => r.success)).toBe(true) - - for (const kind of allFileKinds) { - const expectedFileName = README_FILE_KIND_MAP[kind].out - const expectedPath = path.join(tempDir, projectName, expectedFileName) - expect(fs.existsSync(expectedPath)).toBe(true) - expect(fs.readFileSync(expectedPath, 'utf8')).toBe(`${content}-${kind}`) - } - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 4: Dry-Run Idempotence', () => { - const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) - .filter(s => s.trim().length > 0) - - const fileKindArb = fc.constantFrom(...allFileKinds) - - it('should not create any files in dry-run mode', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - fc.boolean(), - fc.option(subdirNameArb, {nil: void 0}), - fileKindArb, - async (projectName, content, isRoot, subdir, fileKind) => { - const readme = createReadmePrompt(projectName, content, isRoot, tempDir, isRoot ? void 0 : subdir ?? 'subdir', fileKind) - const ctx = createMockWriteContext([readme], tempDir, true) - - const filesBefore = fs.readdirSync(tempDir, {recursive: true}) - - await plugin.writeProjectOutputs(ctx) - - const filesAfter = fs.readdirSync(tempDir, {recursive: true}) - expect(filesAfter).toEqual(filesBefore) - } - ), - {numRuns: 100} - ) - }) - - it('should return success results for all planned operations in dry-run mode', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(projectNameArb, {minLength: 1, maxLength: 5}), - readmeContentArb, - async (projectNames, content) => { - const uniqueProjects = [...new Set(projectNames)] - const readmes = uniqueProjects.map(name => - createReadmePrompt(name, content, true, tempDir)) - const ctx = createMockWriteContext(readmes, tempDir, true) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files.length).toBe(uniqueProjects.length) - for (const result of results.files) { - expect(result.success).toBe(true) - expect(result.skipped).toBe(false) - } - } - ), - {numRuns: 100} - ) - }) - - it('should report same operations in dry-run and normal mode', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readme = createReadmePrompt(projectName, content, true, tempDir) - - const dryRunCtx = createMockWriteContext([readme], tempDir, true) - const dryRunResults = await plugin.writeProjectOutputs(dryRunCtx) - - const normalCtx = createMockWriteContext([readme], tempDir, false) - const normalResults = await plugin.writeProjectOutputs(normalCtx) - - expect(dryRunResults.files.length).toBe(normalResults.files.length) - - for (let i = 0; i < dryRunResults.files.length; i++) expect(dryRunResults.files[i].path.path).toBe(normalResults.files[i].path.path) - } - ), - {numRuns: 100} - ) - }) - - it('should not create files in dry-run mode for all fileKinds', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readmes = allFileKinds.map(kind => - createReadmePrompt(projectName, `${content}-${kind}`, true, tempDir, void 0, kind)) - const ctx = createMockWriteContext(readmes, tempDir, true) - - const filesBefore = fs.readdirSync(tempDir, {recursive: true}) - - await plugin.writeProjectOutputs(ctx) - - const filesAfter = fs.readdirSync(tempDir, {recursive: true}) - expect(filesAfter).toEqual(filesBefore) - } - ), - {numRuns: 100} - ) - }) - }) - - describe('property 5: Clean Operation Completeness', () => { - const projectNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const subdirNameArb = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z][a-z0-9]*$/i.test(s)) - - const readmeContentArb = fc.string({minLength: 1, maxLength: 100}) - .filter(s => s.trim().length > 0) - - it('should register all output file paths for cleanup', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(projectNameArb, {minLength: 1, maxLength: 5}), - readmeContentArb, - async (projectNames, content) => { - const uniqueProjects = [...new Set(projectNames)] - const readmes = uniqueProjects.map(name => - createReadmePrompt(name, content, true, tempDir)) - const ctx = createMockPluginContext(readmes, tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths.length).toBe(uniqueProjects.length) - - for (const registeredPath of registeredPaths) expect(registeredPath.path.endsWith('README.md')).toBe(true) - } - ), - {numRuns: 100} - ) - }) - - it('should register paths for both root and child READMEs', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - subdirNameArb, - readmeContentArb, - async (projectName, subdir, content) => { - const rootReadme = createReadmePrompt(projectName, content, true, tempDir) - const childReadme = createReadmePrompt(projectName, content, false, tempDir, subdir) - const ctx = createMockPluginContext([rootReadme, childReadme], tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths.length).toBe(2) - - const rootPath = registeredPaths.find(p => p.path === path.join(projectName, 'README.md')) - const childPath = registeredPaths.find(p => p.path === path.join(projectName, subdir, 'README.md')) - - expect(rootPath).toBeDefined() - expect(childPath).toBeDefined() - } - ), - {numRuns: 100} - ) - }) - - it('should return empty array when no README prompts exist', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - async () => { - const ctx = createMockPluginContext([], tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths).toEqual([]) - } - ), - {numRuns: 100} - ) - }) - - it('should register correct paths for all fileKinds', async () => { - await fc.assert( - fc.asyncProperty( - projectNameArb, - readmeContentArb, - async (projectName, content) => { - const readmes = allFileKinds.map(kind => - createReadmePrompt(projectName, `${content}-${kind}`, true, tempDir, void 0, kind)) - const ctx = createMockPluginContext(readmes, tempDir) - - const registeredPaths = await plugin.registerProjectOutputFiles(ctx) - - expect(registeredPaths.length).toBe(3) - - for (const kind of allFileKinds) { - const expectedFileName = README_FILE_KIND_MAP[kind].out - const found = registeredPaths.find(p => p.path === path.join(projectName, expectedFileName)) - expect(found).toBeDefined() - } - } - ), - {numRuns: 100} - ) - }) - }) -}) diff --git a/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.ts b/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.ts index 44f7fe75..8458e381 100644 --- a/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.ts +++ b/cli/src/plugins/plugin-readme/ReadmeMdConfigFileOutputPlugin.ts @@ -4,13 +4,13 @@ import type { ReadmeFileKind, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import * as fs from 'node:fs' import * as path from 'node:path' import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {FilePathKind, README_FILE_KIND_MAP} from '@truenine/plugin-shared' +import {FilePathKind, README_FILE_KIND_MAP} from '../plugin-shared' function resolveOutputFileName(fileKind?: ReadmeFileKind): string { return README_FILE_KIND_MAP[fileKind ?? 'Readme'].out 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/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/src/plugins/plugin-shared/types/ShadowSourceProjectTypes.ts b/cli/src/plugins/plugin-shared/types/AindexTypes.ts similarity index 55% rename from cli/src/plugins/plugin-shared/types/ShadowSourceProjectTypes.ts rename to cli/src/plugins/plugin-shared/types/AindexTypes.ts index 61a8788e..6007f43a 100644 --- a/cli/src/plugins/plugin-shared/types/ShadowSourceProjectTypes.ts +++ b/cli/src/plugins/plugin-shared/types/AindexTypes.ts @@ -1,12 +1,12 @@ /** - * Shadow Source Project (aindex) directory structure types and constants + * Aindex directory structure types and constants * Used for directory structure validation and generation */ /** - * File entry in the shadow source project + * File entry in the aindex project */ -export interface ShadowSourceFileEntry { +export interface AindexFileEntry { /** File name (e.g., 'GLOBAL.md') */ readonly name: string /** Whether this file is required */ @@ -16,9 +16,9 @@ export interface ShadowSourceFileEntry { } /** - * Directory entry in the shadow source project + * Directory entry in the aindex project */ -export interface ShadowSourceDirectoryEntry { +export interface AindexDirectoryEntry { /** Directory name (e.g., 'skills') */ readonly name: string /** Whether this directory is required */ @@ -26,52 +26,52 @@ export interface ShadowSourceDirectoryEntry { /** Directory description */ readonly description?: string /** Nested directories */ - readonly directories?: readonly ShadowSourceDirectoryEntry[] + readonly directories?: readonly AindexDirectoryEntry[] /** Files in this directory */ - readonly files?: readonly ShadowSourceFileEntry[] + readonly files?: readonly AindexFileEntry[] } /** - * Root structure of the shadow source project + * Root structure of the aindex project */ -export interface ShadowSourceProjectDirectory { +export interface AindexDirectory { /** Source directories (before compilation) */ readonly src: { - readonly skills: ShadowSourceDirectoryEntry - readonly commands: ShadowSourceDirectoryEntry - readonly agents: ShadowSourceDirectoryEntry - readonly rules: ShadowSourceDirectoryEntry - readonly globalMemoryFile: ShadowSourceFileEntry - readonly workspaceMemoryFile: ShadowSourceFileEntry + readonly skills: AindexDirectoryEntry + readonly commands: AindexDirectoryEntry + readonly agents: AindexDirectoryEntry + readonly rules: AindexDirectoryEntry + readonly globalMemoryFile: AindexFileEntry + readonly workspaceMemoryFile: AindexFileEntry } /** Distribution directories (after compilation) */ readonly dist: { - readonly skills: ShadowSourceDirectoryEntry - readonly commands: ShadowSourceDirectoryEntry - readonly agents: ShadowSourceDirectoryEntry - readonly rules: ShadowSourceDirectoryEntry - readonly app: ShadowSourceDirectoryEntry - readonly globalMemoryFile: ShadowSourceFileEntry - readonly workspaceMemoryFile: ShadowSourceFileEntry + readonly skills: AindexDirectoryEntry + readonly commands: AindexDirectoryEntry + readonly agents: AindexDirectoryEntry + readonly rules: AindexDirectoryEntry + readonly app: AindexDirectoryEntry + readonly globalMemoryFile: AindexFileEntry + readonly workspaceMemoryFile: AindexFileEntry } /** App directory (project-specific prompts source, standalone at root) */ - readonly app: ShadowSourceDirectoryEntry + readonly app: AindexDirectoryEntry /** IDE configuration directories */ readonly ide: { - readonly idea: ShadowSourceDirectoryEntry - readonly ideaCodeStyles: ShadowSourceDirectoryEntry - readonly vscode: ShadowSourceDirectoryEntry + readonly idea: AindexDirectoryEntry + readonly ideaCodeStyles: AindexDirectoryEntry + readonly vscode: AindexDirectoryEntry } /** IDE configuration files */ - readonly ideFiles: readonly ShadowSourceFileEntry[] + readonly ideFiles: readonly AindexFileEntry[] /** AI Agent ignore files */ - readonly ignoreFiles: readonly ShadowSourceFileEntry[] + readonly ignoreFiles: readonly AindexFileEntry[] } /** - * Directory names used in shadow source project + * Directory names used in aindex project */ -export const SHADOW_SOURCE_DIR_NAMES = { +export const AINDEX_DIR_NAMES = { SRC: 'src', DIST: 'dist', SKILLS: 'skills', @@ -85,9 +85,9 @@ export const SHADOW_SOURCE_DIR_NAMES = { } as const /** - * File names used in shadow source project + * File names used in aindex project */ -export const SHADOW_SOURCE_FILE_NAMES = { +export const AINDEX_FILE_NAMES = { GLOBAL_MEMORY: 'global.mdx', // Global memory GLOBAL_MEMORY_SRC: 'global.cn.mdx', WORKSPACE_MEMORY: 'workspace.mdx', // Workspace memory @@ -106,9 +106,9 @@ export const SHADOW_SOURCE_FILE_NAMES = { } as const /** - * Relative paths from shadow source project root + * Relative paths from aindex project root */ -export const SHADOW_SOURCE_RELATIVE_PATHS = { +export const AINDEX_RELATIVE_PATHS = { SRC_SKILLS: 'src/skills', // Source paths SRC_COMMANDS: 'src/commands', SRC_AGENTS: 'src/agents', @@ -126,156 +126,156 @@ export const SHADOW_SOURCE_RELATIVE_PATHS = { } as const /** - * Default shadow source project directory structure + * Default aindex directory structure * Used for validation and generation */ -export const DEFAULT_SHADOW_SOURCE_PROJECT_STRUCTURE: ShadowSourceProjectDirectory = { +export const DEFAULT_AINDEX_STRUCTURE: AindexDirectory = { src: { skills: { - name: SHADOW_SOURCE_DIR_NAMES.SKILLS, + name: AINDEX_DIR_NAMES.SKILLS, required: false, description: 'Skill source files (.cn.mdx)' }, commands: { - name: SHADOW_SOURCE_DIR_NAMES.COMMANDS, + name: AINDEX_DIR_NAMES.COMMANDS, required: false, description: 'Fast command source files (.cn.mdx)' }, agents: { - name: SHADOW_SOURCE_DIR_NAMES.AGENTS, + name: AINDEX_DIR_NAMES.AGENTS, required: false, description: 'Sub-agent source files (.cn.mdx)' }, rules: { - name: SHADOW_SOURCE_DIR_NAMES.RULES, + name: AINDEX_DIR_NAMES.RULES, required: false, description: 'Rule source files (.cn.mdx)' }, globalMemoryFile: { - name: SHADOW_SOURCE_FILE_NAMES.GLOBAL_MEMORY_SRC, + name: AINDEX_FILE_NAMES.GLOBAL_MEMORY_SRC, required: false, description: 'Global memory source file' }, workspaceMemoryFile: { - name: SHADOW_SOURCE_FILE_NAMES.WORKSPACE_MEMORY_SRC, + name: AINDEX_FILE_NAMES.WORKSPACE_MEMORY_SRC, required: false, description: 'Workspace memory source file' } }, dist: { skills: { - name: SHADOW_SOURCE_DIR_NAMES.SKILLS, + name: AINDEX_DIR_NAMES.SKILLS, required: false, description: 'Compiled skill files (.mdx)' }, commands: { - name: SHADOW_SOURCE_DIR_NAMES.COMMANDS, + name: AINDEX_DIR_NAMES.COMMANDS, required: false, description: 'Compiled fast command files (.mdx)' }, agents: { - name: SHADOW_SOURCE_DIR_NAMES.AGENTS, + name: AINDEX_DIR_NAMES.AGENTS, required: false, description: 'Compiled sub-agent files (.mdx)' }, rules: { - name: SHADOW_SOURCE_DIR_NAMES.RULES, + name: AINDEX_DIR_NAMES.RULES, required: false, description: 'Compiled rule files (.mdx)' }, globalMemoryFile: { - name: SHADOW_SOURCE_FILE_NAMES.GLOBAL_MEMORY, + name: AINDEX_FILE_NAMES.GLOBAL_MEMORY, required: false, description: 'Compiled global memory file' }, workspaceMemoryFile: { - name: SHADOW_SOURCE_FILE_NAMES.WORKSPACE_MEMORY, + name: AINDEX_FILE_NAMES.WORKSPACE_MEMORY, required: false, description: 'Compiled workspace memory file' }, app: { - name: SHADOW_SOURCE_DIR_NAMES.APP, + name: AINDEX_DIR_NAMES.APP, required: false, description: 'Compiled project-specific prompts' } }, app: { - name: SHADOW_SOURCE_DIR_NAMES.APP, + name: AINDEX_DIR_NAMES.APP, required: false, description: 'Project-specific prompts (standalone directory)' }, ide: { idea: { - name: SHADOW_SOURCE_DIR_NAMES.IDEA, + name: AINDEX_DIR_NAMES.IDEA, required: false, description: 'JetBrains IDE configuration directory' }, ideaCodeStyles: { - name: SHADOW_SOURCE_DIR_NAMES.IDEA_CODE_STYLES, + name: AINDEX_DIR_NAMES.IDEA_CODE_STYLES, required: false, description: 'JetBrains IDE code styles directory' }, vscode: { - name: SHADOW_SOURCE_DIR_NAMES.VSCODE, + name: AINDEX_DIR_NAMES.VSCODE, required: false, description: 'VS Code configuration directory' } }, ideFiles: [ { - name: SHADOW_SOURCE_FILE_NAMES.EDITOR_CONFIG, + name: AINDEX_FILE_NAMES.EDITOR_CONFIG, required: false, description: 'EditorConfig file' }, { - name: SHADOW_SOURCE_FILE_NAMES.IDEA_GITIGNORE, + name: AINDEX_FILE_NAMES.IDEA_GITIGNORE, required: false, description: 'JetBrains IDE .gitignore' }, { - name: SHADOW_SOURCE_FILE_NAMES.IDEA_PROJECT_XML, + name: AINDEX_FILE_NAMES.IDEA_PROJECT_XML, required: false, description: 'JetBrains IDE Project.xml' }, { - name: SHADOW_SOURCE_FILE_NAMES.IDEA_CODE_STYLE_CONFIG_XML, + name: AINDEX_FILE_NAMES.IDEA_CODE_STYLE_CONFIG_XML, required: false, description: 'JetBrains IDE codeStyleConfig.xml' }, { - name: SHADOW_SOURCE_FILE_NAMES.VSCODE_SETTINGS, + name: AINDEX_FILE_NAMES.VSCODE_SETTINGS, required: false, description: 'VS Code settings.json' }, { - name: SHADOW_SOURCE_FILE_NAMES.VSCODE_EXTENSIONS, + name: AINDEX_FILE_NAMES.VSCODE_EXTENSIONS, required: false, description: 'VS Code extensions.json' } ], ignoreFiles: [ { - name: SHADOW_SOURCE_FILE_NAMES.QODER_IGNORE, + name: AINDEX_FILE_NAMES.QODER_IGNORE, required: false, description: 'Qoder ignore file' }, { - name: SHADOW_SOURCE_FILE_NAMES.CURSOR_IGNORE, + name: AINDEX_FILE_NAMES.CURSOR_IGNORE, required: false, description: 'Cursor ignore file' }, { - name: SHADOW_SOURCE_FILE_NAMES.WARP_INDEX_IGNORE, + name: AINDEX_FILE_NAMES.WARP_INDEX_IGNORE, required: false, description: 'Warp index ignore file' }, { - name: SHADOW_SOURCE_FILE_NAMES.AI_IGNORE, + name: AINDEX_FILE_NAMES.AI_IGNORE, required: false, description: 'AI ignore file' }, { - name: SHADOW_SOURCE_FILE_NAMES.CODEIUM_IGNORE, + name: AINDEX_FILE_NAMES.CODEIUM_IGNORE, required: false, description: 'Windsurf ignore file' } @@ -285,14 +285,44 @@ export const DEFAULT_SHADOW_SOURCE_PROJECT_STRUCTURE: ShadowSourceProjectDirecto /** * Type for directory names */ -export type ShadowSourceDirName = (typeof SHADOW_SOURCE_DIR_NAMES)[keyof typeof SHADOW_SOURCE_DIR_NAMES] +export type AindexDirName = (typeof AINDEX_DIR_NAMES)[keyof typeof AINDEX_DIR_NAMES] /** * Type for file names */ -export type ShadowSourceFileName = (typeof SHADOW_SOURCE_FILE_NAMES)[keyof typeof SHADOW_SOURCE_FILE_NAMES] +export type AindexFileName = (typeof AINDEX_FILE_NAMES)[keyof typeof AINDEX_FILE_NAMES] /** * Type for relative paths */ -export type ShadowSourceRelativePath = (typeof SHADOW_SOURCE_RELATIVE_PATHS)[keyof typeof SHADOW_SOURCE_RELATIVE_PATHS] +export type AindexRelativePath = (typeof AINDEX_RELATIVE_PATHS)[keyof typeof AINDEX_RELATIVE_PATHS] // Backward compatibility aliases (deprecated, use Aindex* versions instead) + +/** @deprecated Use AindexFileEntry instead */ +export type ShadowSourceFileEntry = AindexFileEntry + +/** @deprecated Use AindexDirectoryEntry instead */ +export type ShadowSourceDirectoryEntry = AindexDirectoryEntry + +/** @deprecated Use AindexDirectory instead */ +export type ShadowSourceProjectDirectory = AindexDirectory + +/** @deprecated Use AindexDirName instead */ +export type ShadowSourceDirName = AindexDirName + +/** @deprecated Use AindexFileName instead */ +export type ShadowSourceFileName = AindexFileName + +/** @deprecated Use AindexRelativePath instead */ +export type ShadowSourceRelativePath = AindexRelativePath + +/** @deprecated Use AINDEX_DIR_NAMES instead */ +export const SHADOW_SOURCE_DIR_NAMES = AINDEX_DIR_NAMES + +/** @deprecated Use AINDEX_FILE_NAMES instead */ +export const SHADOW_SOURCE_FILE_NAMES = AINDEX_FILE_NAMES + +/** @deprecated Use AINDEX_RELATIVE_PATHS instead */ +export const SHADOW_SOURCE_RELATIVE_PATHS = AINDEX_RELATIVE_PATHS + +/** @deprecated Use DEFAULT_AINDEX_STRUCTURE instead */ +export const DEFAULT_SHADOW_SOURCE_PROJECT_STRUCTURE = DEFAULT_AINDEX_STRUCTURE diff --git a/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.property.test.ts b/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.property.test.ts deleted file mode 100644 index 2f0ccff3..00000000 --- a/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.property.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -import {ZProjectConfig, ZTypeSeriesConfig} from './ConfigTypes.schema' - -describe('zProjectConfig property tests', () => { // Property 7: Zod schema round-trip. Validates: Requirement 1.5 - const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // alphanumeric series names - .filter(s => /^[\w-]+$/.test(s) && s !== '__proto__' && s !== 'constructor' && s !== 'prototype') - - const includeSeriesArb = fc.option( // optional string[] - fc.array(seriesNameArb, {minLength: 0, maxLength: 5}), - {nil: void 0} - ) - - const subSeriesArb = fc.option( // optional Record - fc.dictionary( - seriesNameArb, - fc.array(seriesNameArb, {minLength: 1, maxLength: 3}), - {minKeys: 0, maxKeys: 3} - ), - {nil: void 0} - ) - - function stripUndefined(obj: Record): Record { // strip undefined to match Zod output - const result: Record = {} - for (const [key, value] of Object.entries(obj)) { - if (value !== void 0) result[key] = value - } - return result - } - - const typeSeriesConfigArb = fc.option( // optional TypeSeriesConfig - fc.record({ - includeSeries: includeSeriesArb, - subSeries: subSeriesArb - }).map(obj => stripUndefined(obj)), - {nil: void 0} - ) - - const projectConfigArb = fc.record({ // valid ProjectConfig (no mcp for simplicity) - includeSeries: includeSeriesArb, - subSeries: subSeriesArb, - rules: typeSeriesConfigArb, - skills: typeSeriesConfigArb, - subAgents: typeSeriesConfigArb, - commands: typeSeriesConfigArb - }).map(obj => stripUndefined(obj)) - - it('property 7: round-trip through JSON serialization preserves equivalence', () => { // Validates: Requirement 1.5 - fc.assert( - fc.property( - projectConfigArb, - config => { - const json = JSON.stringify(config) - const parsed = ZProjectConfig.parse(JSON.parse(json)) - expect(parsed).toEqual(config) - } - ), - {numRuns: 200} - ) - }) - - it('property 7: rejects configurations with incorrect includeSeries types', () => { // Validates: Requirement 1.5 - fc.assert( - fc.property( - fc.oneof( - fc.integer(), - fc.boolean(), - fc.constant('not-an-array') - ), - invalidValue => { - expect(() => ZProjectConfig.parse({includeSeries: invalidValue})).toThrow() - } - ), - {numRuns: 50} - ) - }) - - it('property 7: ZTypeSeriesConfig round-trip through JSON serialization', () => { // Validates: Requirement 1.4 - fc.assert( - fc.property( - typeSeriesConfigArb.filter((v): v is Record => v !== void 0), - config => { - const json = JSON.stringify(config) - const parsed = ZTypeSeriesConfig.parse(JSON.parse(json)) - expect(parsed).toEqual(config) - } - ), - {numRuns: 200} - ) - }) -}) diff --git a/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts b/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts index 33925197..ec1bfe6c 100644 --- a/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts +++ b/cli/src/plugins/plugin-shared/types/ConfigTypes.schema.ts @@ -14,32 +14,53 @@ export const ZShadowSourceProjectDirPair = z.object({ /** * Zod schema for the shadow source project configuration. * All paths are relative to `/`. + * @deprecated Use ZAindexConfig instead. */ export const ZShadowSourceProjectConfig = z.object({ - name: z.string(), - skill: ZShadowSourceProjectDirPair, - fastCommand: ZShadowSourceProjectDirPair, - subAgent: ZShadowSourceProjectDirPair, - rule: ZShadowSourceProjectDirPair, - globalMemory: ZShadowSourceProjectDirPair, - workspaceMemory: ZShadowSourceProjectDirPair, - project: ZShadowSourceProjectDirPair + dir: z.string().default('aindex'), + skills: ZShadowSourceProjectDirPair, + commands: ZShadowSourceProjectDirPair, + subAgents: ZShadowSourceProjectDirPair, + rules: ZShadowSourceProjectDirPair, + globalPrompt: ZShadowSourceProjectDirPair, + workspacePrompt: ZShadowSourceProjectDirPair, + app: ZShadowSourceProjectDirPair, + ext: ZShadowSourceProjectDirPair, + arch: ZShadowSourceProjectDirPair }) /** - * Zod schema for per-plugin fast command series override options + * Zod schema for the aindex configuration. + * This is the user-facing configuration format in ~/.aindex/.tnmsc.json + * All paths are relative to `/`. + */ +export const ZAindexConfig = z.object({ + dir: z.string().default('aindex'), + skills: ZShadowSourceProjectDirPair, + commands: ZShadowSourceProjectDirPair, + subAgents: ZShadowSourceProjectDirPair, + rules: ZShadowSourceProjectDirPair, + globalPrompt: ZShadowSourceProjectDirPair, + workspacePrompt: ZShadowSourceProjectDirPair, + app: ZShadowSourceProjectDirPair, + ext: ZShadowSourceProjectDirPair, + arch: ZShadowSourceProjectDirPair +}) + +/** + * Zod schema for per-plugin command series override options */ -export const ZFastCommandSeriesPluginOverride = z.object({ +export const ZCommandSeriesPluginOverride = z.object({ includeSeriesPrefix: z.boolean().optional(), seriesSeparator: z.string().optional() }) /** - * Zod schema for fast command series configuration options + * Zod schema for command series configuration options */ -export const ZFastCommandSeriesOptions = z.object({ +export const ZCommandSeriesOptions = z.object({ includeSeriesPrefix: z.boolean().optional(), - pluginOverrides: z.record(z.string(), ZFastCommandSeriesPluginOverride).optional() + pluginOverrides: z.record(z.string(), ZCommandSeriesPluginOverride).optional() }) /** @@ -54,17 +75,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 'aindex' format and legacy 'shadowSourceProject' format. + * Note: Both formats have the same structure, shadowSourceProject is kept for backward compatibility. */ export const ZUserConfigFile = z.object({ version: z.string().optional(), workspaceDir: z.string().optional(), + /** Aindex configuration */ + aindex: ZAindexConfig.optional(), + /** @deprecated Use aindex instead. Kept for backward compatibility. */ shadowSourceProject: ZShadowSourceProjectConfig.optional(), logLevel: z.enum(['trace', 'debug', 'info', 'warn', 'error']).optional(), - fastCommandSeriesOptions: ZFastCommandSeriesOptions.optional(), + commandSeriesOptions: ZCommandSeriesOptions.optional(), profile: ZUserProfile.optional() }) +/** + * Convert UserConfigFile to ensure aindex field is populated. + * If shadowSourceProject is provided but aindex is not, copies shadowSourceProject to aindex. + * @deprecated This function is kept for backward compatibility. + */ +export function convertUserConfigAindexToShadowSourceProject( + config: z.infer +): z.infer { + if (config.aindex != null) { // If aindex is explicitly provided, use it directly + return config + } + + if (config.shadowSourceProject != null) { // If shadowSourceProject is provided but aindex is not, copy it to aindex + return { + ...config, + aindex: config.shadowSourceProject + } + } + + return config // Neither format provided - return as-is +} + /** * Zod schema for MCP project config */ @@ -102,10 +149,15 @@ export const ZConfigLoaderOptions = z.object({ searchGlobal: z.boolean().optional() }) -export type ShadowSourceProjectDirPair = z.infer -export type ShadowSourceProjectConfig = z.infer -export type FastCommandSeriesPluginOverride = z.infer -export type FastCommandSeriesOptions = z.infer +export type AindexDirPair = z.infer +export type AindexConfig = z.infer + +/** @deprecated Use AindexDirPair instead */ +export type ShadowSourceProjectDirPair = AindexDirPair +/** @deprecated Use AindexConfig instead */ +export type ShadowSourceProjectConfig = AindexConfig +export type CommandSeriesPluginOverride = z.infer +export type CommandSeriesOptions = z.infer export type UserConfigFile = z.infer export type McpProjectConfig = z.infer export type TypeSeriesConfig = z.infer diff --git a/cli/src/plugins/plugin-shared/types/Enums.ts b/cli/src/plugins/plugin-shared/types/Enums.ts index 6b6db7b3..c782b9cf 100644 --- a/cli/src/plugins/plugin-shared/types/Enums.ts +++ b/cli/src/plugins/plugin-shared/types/Enums.ts @@ -7,7 +7,7 @@ export enum PromptKind { GlobalMemory = 'GlobalMemory', ProjectRootMemory = 'ProjectRootMemory', ProjectChildrenMemory = 'ProjectChildrenMemory', - FastCommand = 'FastCommand', + Command = 'Command', SubAgent = 'SubAgent', Skill = 'Skill', SkillChildDoc = 'SkillChildDoc', @@ -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 361e0c60..7d16353f 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,20 +26,24 @@ 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 { +export interface CommandExportMetadata extends BaseExportMetadata { readonly description?: string 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 } /** @@ -116,14 +123,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 + } } /** @@ -133,11 +156,11 @@ export function validateSkillMetadata( * @param filePath - Optional file path for error messages * @returns Validation result */ -export function validateFastCommandMetadata( +export function validateCommandMetadata( metadata: Record, filePath?: string ): MetadataValidationResult { - return validateExportMetadata(metadata, { // description is optional (can come from YAML or be omitted) // FastCommand has no required fields from export metadata + return validateExportMetadata(metadata, { // description is optional (can come from YAML or be omitted) // Command has no required fields from export metadata requiredFields: [], optionalDefaults: {}, filePath @@ -183,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 9f67eed8..b687c68e 100644 --- a/cli/src/plugins/plugin-shared/types/InputTypes.ts +++ b/cli/src/plugins/plugin-shared/types/InputTypes.ts @@ -6,13 +6,15 @@ import type { RuleScope } from './Enums' import type {FileContent, Path, RelativePath} from './FileSystemTypes' +import type {LocalizedPrompt, PromptsContext} from './LocalizedTypes' import type { - FastCommandYAMLFrontMatter, + CommandYAMLFrontMatter, GlobalMemoryPrompt, ProjectChildrenMemoryPrompt, ProjectRootMemoryPrompt, Prompt, RuleYAMLFrontMatter, + SeriName, SkillYAMLFrontMatter, SubAgentYAMLFrontMatter } from './PromptTypes' @@ -51,19 +53,31 @@ export interface AIAgentIgnoreConfigFile { */ export interface CollectedInputContext { readonly workspace: Workspace - readonly vscodeConfigFiles?: readonly ProjectIDEConfigFile[] - readonly jetbrainsConfigFiles?: readonly ProjectIDEConfigFile[] - readonly editorConfigFiles?: readonly ProjectIDEConfigFile[] - readonly fastCommands?: readonly FastCommandPrompt[] - readonly subAgents?: readonly SubAgentPrompt[] + readonly 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 commands?: readonly CommandPrompt[] + /** @deprecated Use prompts.subAgents instead */ + readonly subAgents?: readonly SubAgentPrompt[] + /** @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[] + readonly aindexDir?: string } /** @@ -75,19 +89,19 @@ export interface RulePrompt extends Prompt { - readonly type: PromptKind.FastCommand +export interface CommandPrompt extends Prompt { + readonly type: PromptKind.Command readonly globalOnly?: true - readonly series?: string + readonly commandPrefix?: string readonly commandName: string - readonly seriName?: string | string[] | null + readonly seriName?: SeriName readonly rawMdxContent?: string } @@ -96,9 +110,9 @@ export interface FastCommandPrompt extends Prompt { readonly type: PromptKind.SubAgent - readonly series?: string + readonly agentPrefix?: string readonly agentName: string - readonly seriName?: string | string[] | null + readonly seriName?: SeriName readonly rawMdxContent?: string } @@ -384,7 +398,7 @@ export interface SkillPrompt extends Prompt { + /** 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 LocalizedCommandPrompt = LocalizedPrompt< + import('./InputTypes').CommandPrompt, + PromptKind.Command +> + +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[] + + /** Command prompts with localization */ + readonly commands: LocalizedCommandPrompt[] + + /** 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/PluginTypes.ts b/cli/src/plugins/plugin-shared/types/PluginTypes.ts index cdc9cf06..0053dad0 100644 --- a/cli/src/plugins/plugin-shared/types/PluginTypes.ts +++ b/cli/src/plugins/plugin-shared/types/PluginTypes.ts @@ -1,6 +1,6 @@ import type {ILogger} from '@truenine/logger' import type {MdxGlobalScope} from '@truenine/md-compiler/globals' -import type {FastCommandSeriesOptions, ShadowSourceProjectConfig} from './ConfigTypes.schema' +import type {AindexConfig, CommandSeriesOptions} from './ConfigTypes.schema' import type {PluginKind} from './Enums' import type {RelativePath} from './FileSystemTypes' import type { @@ -162,8 +162,8 @@ export interface InputEffectContext { readonly userConfigOptions: PluginOptions /** Resolved workspace directory */ readonly workspaceDir: string - /** Resolved shadow project directory */ - readonly shadowProjectDir: string + /** Resolved aindex directory */ + readonly aindexDir: string /** Whether running in dry-run mode */ readonly dryRun?: boolean } @@ -192,8 +192,8 @@ export interface InputEffectRegistration { export interface ResolvedBasePaths { /** The resolved workspace directory path */ readonly workspaceDir: string - /** The resolved shadow project directory path */ - readonly shadowProjectDir: string + /** The resolved aindex directory path */ + readonly aindexDir: string } /** @@ -383,9 +383,9 @@ export interface PluginOptions { readonly workspaceDir?: string - readonly shadowSourceProject?: ShadowSourceProjectConfig + readonly aindex?: AindexConfig - readonly fastCommandSeriesOptions?: FastCommandSeriesOptions + readonly commandSeriesOptions?: CommandSeriesOptions plugins?: Plugin[] logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' diff --git a/cli/src/plugins/plugin-shared/types/PromptTypes.ts b/cli/src/plugins/plugin-shared/types/PromptTypes.ts index 8504237f..ccc4dd22 100644 --- a/cli/src/plugins/plugin-shared/types/PromptTypes.ts +++ b/cli/src/plugins/plugin-shared/types/PromptTypes.ts @@ -25,6 +25,12 @@ export interface YAMLFrontMatter 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 +export interface CommandYAMLFrontMatter extends ToolAwareYAMLFrontMatter { + 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 } /** diff --git a/cli/src/plugins/plugin-shared/types/index.ts b/cli/src/plugins/plugin-shared/types/index.ts index 7d516e26..c6df13eb 100644 --- a/cli/src/plugins/plugin-shared/types/index.ts +++ b/cli/src/plugins/plugin-shared/types/index.ts @@ -1,11 +1,12 @@ +export * from './AindexTypes' export * from './ConfigTypes.schema' export * from './Enums' export * from './Errors' export * from './ExportMetadataTypes' export * from './FileSystemTypes' export * from './InputTypes' +export * from './LocalizedTypes' export * from './OutputTypes' export * from './PluginTypes' export * from './PromptTypes' export * from './RegistryTypes' -export * from './ShadowSourceProjectTypes' diff --git a/cli/src/plugins/plugin-shared/types/seriNamePropagation.property.test.ts b/cli/src/plugins/plugin-shared/types/seriNamePropagation.property.test.ts deleted file mode 100644 index 340e7c90..00000000 --- a/cli/src/plugins/plugin-shared/types/seriNamePropagation.property.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** Property 8: seriName front matter propagation. Validates: Requirement 2.5 */ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' - -const seriesNameArb = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^[\w-]+$/.test(s) && !['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes(s)) - -const seriNameArb: fc.Arbitrary = fc.oneof( - fc.constant(null), - fc.constant(void 0), - seriesNameArb, - fc.array(seriesNameArb, {minLength: 1, maxLength: 5}) -) - -function propagateSeriName( - frontMatter: {readonly seriName?: string | string[] | null} | undefined -): {readonly seriName?: string | string[] | null} { - const seriName = frontMatter?.seriName - return { - ...seriName != null && {seriName} - } -} - -describe('property 8: seriName front matter propagation', () => { - it('propagated seriName matches front matter value for non-null/undefined values', () => { // **Validates: Requirement 2.5** - fc.assert( - fc.property( - seriNameArb, - seriName => { - const frontMatter = seriName === void 0 ? {} : {seriName} - const result = propagateSeriName(frontMatter) - - if (seriName == null) { - expect(result.seriName).toBeUndefined() // null and undefined should not appear on the prompt object - } else { - expect(result.seriName).toEqual(seriName) // string and string[] should be propagated exactly - } - } - ), - {numRuns: 200} - ) - }) - - it('undefined front matter produces no seriName on prompt', () => { // **Validates: Requirement 2.5** - fc.assert( - fc.property( - fc.constant(void 0), - frontMatter => { - const result = propagateSeriName(frontMatter) - expect(result.seriName).toBeUndefined() - } - ), - {numRuns: 10} - ) - }) - - it('string seriName is always propagated identically', () => { // **Validates: Requirement 2.5** - fc.assert( - fc.property( - seriesNameArb, - seriName => { - const result = propagateSeriName({seriName}) - expect(result.seriName).toBe(seriName) - } - ), - {numRuns: 200} - ) - }) - - it('array seriName is always propagated identically', () => { // **Validates: Requirement 2.5** - fc.assert( - fc.property( - fc.array(seriesNameArb, {minLength: 1, maxLength: 5}), - seriName => { - const result = propagateSeriName({seriName}) - expect(result.seriName).toEqual(seriName) - } - ), - {numRuns: 200} - ) - }) -}) diff --git a/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..c3472932 --- /dev/null +++ b/cli/src/plugins/plugin-trae-cn-ide/TraeCNIDEOutputPlugin.ts @@ -0,0 +1,72 @@ +import type { + OutputPluginContext, + OutputWriteContext, + WriteResult, + WriteResults +} from '../plugin-shared' +import type {RelativePath} from '../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 deleted file mode 100644 index 07a71e45..00000000 --- a/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type {FastCommandPrompt, OutputWriteContext, Project, ProjectChildrenMemoryPrompt, RelativePath, WriteResult} from '@truenine/plugin-shared' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' -import {TraeIDEOutputPlugin} from './TraeIDEOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => `${basePath}/${pathStr}` - } -} - -function createMockFastCommandPrompt( - series: string | undefined, - commandName: string -): FastCommandPrompt { - return { - type: PromptKind.FastCommand, - series, - commandName, - content: '', - length: 0, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', '/test'), - markdownContents: [] - } as FastCommandPrompt -} - -class TestableTraeIDEOutputPlugin extends TraeIDEOutputPlugin { - private mockHomeDir: string | null = null - public capturedWriteFile: {path: string, content: string} | null = null - - public testBuildFastCommandSteeringFileName(cmd: FastCommandPrompt): string { - return (this as any).buildFastCommandSteeringFileName(cmd) - } - - public async testWriteSteeringFile(ctx: OutputWriteContext, project: Project, child: ProjectChildrenMemoryPrompt): Promise { - return (this as any).writeSteeringFile(ctx, project, child) - } - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } - - protected override async writeFile(_ctx: OutputWriteContext, path: string, content: string): Promise { - this.capturedWriteFile = {path, content} - return {success: true, description: 'Mock write', filePath: path} - } -} - -describe('traeIDEOutputPlugin', () => { - describe('buildFastCommandSteeringFileName', () => { - const alphanumericNoUnderscore = fc.string({minLength: 1, maxLength: 10, unit: 'grapheme-ascii'}) - .filter(s => /^[a-z0-9]+$/i.test(s)) - - const alphanumericCommandName = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) - .filter(s => /^\w+$/.test(s)) - - it('should use hyphen separator between series and command name', () => { - fc.assert( - fc.property( - alphanumericNoUnderscore, - alphanumericCommandName, - (series, commandName) => { - const plugin = new TestableTraeIDEOutputPlugin() - const cmd = createMockFastCommandPrompt(series, commandName) - - const result = plugin.testBuildFastCommandSteeringFileName(cmd) - - expect(result).toBe(`${series}-${commandName}.md`) - } - ), - {numRuns: 100} - ) - }) - - it('should return just commandName.md when series is undefined', () => { - fc.assert( - fc.property( - alphanumericCommandName, - commandName => { - const plugin = new TestableTraeIDEOutputPlugin() - const cmd = createMockFastCommandPrompt(void 0, commandName) - - const result = plugin.testBuildFastCommandSteeringFileName(cmd) - - expect(result).toBe(`${commandName}.md`) - } - ), - {numRuns: 100} - ) - }) - }) - - describe('writeSteeringFile (child memory prompts)', () => { - it('should write to .trae/rules with correct frontmatter', async () => { - const plugin = new TestableTraeIDEOutputPlugin() - const project = { - dirFromWorkspacePath: { - path: 'packages/pkg-a', - basePath: '/workspace' - } - } as any - const child = { - dir: {path: 'src/components'}, - workingChildDirectoryPath: {path: 'src/components'}, - content: 'child content' - } as any - const ctx = { - dryRun: false - } as any - - await plugin.testWriteSteeringFile(ctx, project, child) - - expect(plugin.capturedWriteFile).not.toBeNull() - const {path, content} = plugin.capturedWriteFile! - - expect(path.replaceAll('\\', '/')).toContain('/.trae/rules/') // Verify path contains .trae/rules - - expect(content).toContain('---') // Verify frontmatter - expect(content).toContain('alwaysApply: false') - expect(content).toContain('globs: src/components/**') - expect(content).toContain('child content') - }) - }) -}) diff --git a/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts index 4632762f..7bda3cf2 100644 --- a/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts +++ b/cli/src/plugins/plugin-trae-ide/TraeIDEOutputPlugin.ts @@ -1,20 +1,27 @@ import type { - FastCommandPrompt, + CommandPrompt, OutputPluginContext, OutputWriteContext, Project, ProjectChildrenMemoryPrompt, + SkillPrompt, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../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 '../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 {commands, 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 (commands != null && commands.length > 0) { // Register commands dir (new: per-project) + const filteredCommands = filterCommandsByProjectConfig(commands, 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 {commands, 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 (commands != null && commands.length > 0) { // Commands (new: per-project) + const filteredCommands = filterCommandsByProjectConfig(commands, projectConfig) + const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + for (const cmd of filteredCommands) { + const fileName = this.transformCommandName(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, commands, skills, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasChildPrompts = workspace.projects.some(p => (p.childMemoryPrompts?.length ?? 0) > 0) + const hasCommands = (commands?.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 || hasCommands || hasSkills || hasTraeIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } async writeProjectOutputs(ctx: OutputWriteContext): Promise { const {projects} = ctx.collectedInputContext.workspace + const {commands, 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 (commands != null && commands.length > 0) { // Commands (new: per-project) + const filteredCommands = filterCommandsByProjectConfig(commands, projectConfig) + for (const cmd of filteredCommands) fileResults.push(await this.writeProjectCommand(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 writeProjectCommand(ctx: OutputWriteContext, projectDir: RelativePath, cmd: CommandPrompt): Promise { + const transformOptions = this.getTransformOptionsFromContext(ctx, {includeSeriesPrefix: true}) + const fileName = this.transformCommandName(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: 'projectCommand', + 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 { diff --git a/cli/src/plugins/plugin-vscode/VisualStudioCodeIDEConfigOutputPlugin.ts b/cli/src/plugins/plugin-vscode/VisualStudioCodeIDEConfigOutputPlugin.ts index f58a4eaf..e415e3ac 100644 --- a/cli/src/plugins/plugin-vscode/VisualStudioCodeIDEConfigOutputPlugin.ts +++ b/cli/src/plugins/plugin-vscode/VisualStudioCodeIDEConfigOutputPlugin.ts @@ -3,10 +3,10 @@ import type { OutputWriteContext, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {FilePathKind, IDEKind} from '@truenine/plugin-shared' +import {FilePathKind, IDEKind} from '../plugin-shared' const VSCODE_DIR = '.vscode' diff --git a/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.test.ts b/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.test.ts deleted file mode 100644 index c61ea29c..00000000 --- a/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.test.ts +++ /dev/null @@ -1,513 +0,0 @@ -import type { - CollectedInputContext, - GlobalMemoryPrompt, - OutputPluginContext, - OutputWriteContext, - ProjectRootMemoryPrompt, - RelativePath -} from '@truenine/plugin-shared' -import fs from 'node:fs' -import path from 'node:path' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {WarpIDEOutputPlugin} from './WarpIDEOutputPlugin' - -vi.mock('node:fs') // Mock fs module - -describe('warpIDEOutputPlugin', () => { - const mockWorkspaceDir = '/workspace/test' - let plugin: WarpIDEOutputPlugin - - beforeEach(() => { - plugin = new WarpIDEOutputPlugin() - vi.mocked(fs.existsSync).mockReturnValue(true) - vi.mocked(fs.mkdirSync).mockReturnValue(void 0) - vi.mocked(fs.writeFileSync).mockReturnValue(void 0) - }) - - afterEach(() => vi.clearAllMocks()) - - function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => path.basename(pathStr), - getAbsolutePath: () => path.join(basePath, pathStr) - } - } - - function createMockRootMemoryPrompt(content: string): ProjectRootMemoryPrompt { - return { - type: PromptKind.ProjectRootMemory, - content, - dir: createMockRelativePath('.', mockWorkspaceDir), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative - } as ProjectRootMemoryPrompt - } - - function createMockGlobalMemoryPrompt(content: string): GlobalMemoryPrompt { - return { - type: PromptKind.GlobalMemory, - content, - dir: createMockRelativePath('.', mockWorkspaceDir), - markdownContents: [], - length: content.length, - filePathKind: FilePathKind.Relative, - parentDirectoryPath: { - type: 'UserHome', - directory: createMockRelativePath('.memory', '/home/user') - } - } as GlobalMemoryPrompt - } - - function createMockOutputWriteContext( - collectedInputContext: Partial, - dryRun = false, - registeredPluginNames: readonly string[] = [] - ): OutputWriteContext { - return { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [] - }, - ideConfigFiles: [], - ...collectedInputContext - } as CollectedInputContext, - dryRun, - registeredPluginNames - } - } - - describe('registerProjectOutputFiles', () => { - it('should register WARP.md for project with rootMemoryPrompt', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('test content') - } - ] - }, - ideConfigFiles: [] - } as CollectedInputContext - } - - const results = await plugin.registerProjectOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('project1', 'WARP.md')) - }) - - it('should register WARP.md for child memory prompts', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const childDir = createMockRelativePath('project1/src', mockWorkspaceDir) - - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - childMemoryPrompts: [ - { - type: PromptKind.ProjectChildrenMemory, - dir: childDir, - content: 'child content', - workingChildDirectoryPath: childDir, - markdownContents: [], - length: 13, - filePathKind: FilePathKind.Relative - } - ] - } - ] - }, - ideConfigFiles: [] - } as CollectedInputContext - } - - const results = await plugin.registerProjectOutputFiles(ctx) - - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('project1', 'src', 'WARP.md')) - }) - - it('should return empty array when no prompts exist', async () => { - const ctx: OutputPluginContext = { - collectedInputContext: { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project' - } - ] - }, - ideConfigFiles: [] - } as CollectedInputContext - } - - const results = await plugin.registerProjectOutputFiles(ctx) - - expect(results).toHaveLength(0) - }) - }) - - describe('canWrite', () => { - it('should return false when AgentsOutputPlugin is registered', async () => { - const ctx = createMockOutputWriteContext( - { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), - rootMemoryPrompt: createMockRootMemoryPrompt('test content') - } - ] as any - } as any - }, - false, - ['AgentsOutputPlugin'] - ) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(false) - }) - - it('should return true when project has rootMemoryPrompt and AgentsOutputPlugin is not registered', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), - rootMemoryPrompt: createMockRootMemoryPrompt('test content') - } - ] as any - } as any - }) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(true) - }) - - it('should return true when project has childMemoryPrompts', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), - childMemoryPrompts: [ - { - type: PromptKind.ProjectChildrenMemory, - dir: createMockRelativePath('project1/src', mockWorkspaceDir), - content: 'child content', - workingChildDirectoryPath: createMockRelativePath('src', mockWorkspaceDir), - markdownContents: [], - length: 13, - filePathKind: FilePathKind.Relative - } - ] - } - ] as any - } as any - }) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(true) - }) - - it('should return false when no outputs exist', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project' - } - ] as any - } as any - }) - - const result = await plugin.canWrite(ctx) - - expect(result).toBe(false) - }) - }) - - describe('writeProjectOutputs', () => { - it('should write rootMemoryPrompt without globalMemory', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('# Project Rules\n\nThis is project content.') - } - ] as any - } as any - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(1) - expect(results.files[0].success).toBe(true) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - expect.stringContaining('WARP.md'), - '# Project Rules\n\nThis is project content.', - 'utf8' - ) - }) - - it('should combine globalMemory with rootMemoryPrompt', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const globalMemory = createMockGlobalMemoryPrompt('# Global Rules\n\nThese are global rules.') - const rootMemory = createMockRootMemoryPrompt('# Project Rules\n\nThese are project rules.') - - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: rootMemory - } - ] as any - } as any, - globalMemory - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(1) - expect(results.files[0].success).toBe(true) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - expect.stringContaining('WARP.md'), - '# Global Rules\n\nThese are global rules.\n\n# Project Rules\n\nThese are project rules.', - 'utf8' - ) - }) - - it('should prepend globalMemory to the beginning of rootMemoryPrompt', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const globalContent = 'Global content first' - const projectContent = 'Project content second' - const globalMemory = createMockGlobalMemoryPrompt(globalContent) - const rootMemory = createMockRootMemoryPrompt(projectContent) - - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: rootMemory - } - ] as any - } as any, - globalMemory - }) - - await plugin.writeProjectOutputs(ctx) - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0] - const writtenContent = writeCall[1] as string - - const globalIndex = writtenContent.indexOf(globalContent) // Verify global content comes first - const projectIndex = writtenContent.indexOf(projectContent) - - expect(globalIndex).toBeGreaterThanOrEqual(0) - expect(projectIndex).toBeGreaterThan(globalIndex) - expect(writtenContent).toBe(`${globalContent}\n\n${projectContent}`) - }) - - it('should skip globalMemory if it is empty or whitespace', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const globalMemory = createMockGlobalMemoryPrompt(' \n\n ') - const rootMemory = createMockRootMemoryPrompt('# Project Rules') - - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: rootMemory - } - ] as any - } as any, - globalMemory - }) - - await plugin.writeProjectOutputs(ctx) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - expect.stringContaining('WARP.md'), - '# Project Rules', - 'utf8' - ) - }) - - it('should write multiple projects with globalMemory', async () => { - const globalMemory = createMockGlobalMemoryPrompt('Global rules') - - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'project-1', - dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir), - rootMemoryPrompt: createMockRootMemoryPrompt('Project 1 rules') - }, - { - name: 'project-2', - dirFromWorkspacePath: createMockRelativePath('project2', mockWorkspaceDir), - rootMemoryPrompt: createMockRootMemoryPrompt('Project 2 rules') - } - ] as any - } as any, - globalMemory - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(2) - expect(results.files[0].success).toBe(true) - expect(results.files[1].success).toBe(true) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(2) // Verify both files have global memory prepended - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('project1'), - 'Global rules\n\nProject 1 rules', - 'utf8' - ) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('project2'), - 'Global rules\n\nProject 2 rules', - 'utf8' - ) - }) - - it('should not add globalMemory to child prompts', async () => { - const globalMemory = createMockGlobalMemoryPrompt('Global rules') - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const childDir = createMockRelativePath('project1/src', mockWorkspaceDir) - - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('Root rules'), - childMemoryPrompts: [ - { - type: PromptKind.ProjectChildrenMemory, - dir: childDir, - content: 'Child rules', - workingChildDirectoryPath: childDir, - markdownContents: [], - length: 11, - filePathKind: FilePathKind.Relative - } - ] - } - ] as any - } as any, - globalMemory - }) - - await plugin.writeProjectOutputs(ctx) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledTimes(2) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( // Root prompt should have global memory - 1, - expect.stringContaining(path.join('project1', 'WARP.md')), - 'Global rules\n\nRoot rules', - 'utf8' - ) - - expect(vi.mocked(fs.writeFileSync)).toHaveBeenNthCalledWith( // Child prompt should NOT have global memory - 2, - expect.stringContaining(path.join('project1', 'src', 'WARP.md')), - 'Child rules', - 'utf8' - ) - }) - - it('should skip project without dirFromWorkspacePath', async () => { - const ctx = createMockOutputWriteContext({ - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - rootMemoryPrompt: createMockRootMemoryPrompt('content') - } - ] as any - } as any - }) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(0) - expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() - }) - - it('should support dry-run mode', async () => { - const projectDir = createMockRelativePath('project1', mockWorkspaceDir) - const ctx = createMockOutputWriteContext( - { - workspace: { - directory: createMockRelativePath('.', mockWorkspaceDir), - projects: [ - { - name: 'test-project', - dirFromWorkspacePath: projectDir, - rootMemoryPrompt: createMockRootMemoryPrompt('test content') - } - ] as any - } as any - }, - true - ) - - const results = await plugin.writeProjectOutputs(ctx) - - expect(results.files).toHaveLength(1) - expect(results.files[0].success).toBe(true) - expect(results.files[0].skipped).toBe(false) - expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled() - }) - }) -}) diff --git a/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.ts b/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.ts index 18f0a8f4..476f1c2a 100644 --- a/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.ts +++ b/cli/src/plugins/plugin-warp-ide/WarpIDEOutputPlugin.ts @@ -3,10 +3,10 @@ import type { OutputWriteContext, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import {AbstractOutputPlugin} from '@truenine/plugin-output-shared' -import {PLUGIN_NAMES} from '@truenine/plugin-shared' +import {PLUGIN_NAMES} from '../plugin-shared' const PROJECT_MEMORY_FILE = 'WARP.md' diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts deleted file mode 100644 index 18355b09..00000000 --- a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.projectConfig.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type {OutputPluginContext} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {collectFileNames, createMockProject, createMockRulePrompt} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' - -class TestableWindsurfOutputPlugin extends WindsurfOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -function createMockContext( - tempDir: string, - rules: unknown[], - projects: unknown[] -): OutputPluginContext { - return { - collectedInputContext: { - workspace: { - projects: projects as never, - directory: { - pathKind: 1, - path: tempDir, - basePath: tempDir, - getDirectoryName: () => 'workspace', - getAbsolutePath: () => tempDir - } - }, - ideConfigFiles: [], - rules: rules as never, - fastCommands: [], - skills: [], - globalMemory: void 0, - aiAgentIgnoreConfigFiles: [] - }, - logger: { - debug: vi.fn(), - trace: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } as never, - fs, - path, - glob: vi.fn() as never - } -} - -describe('windsurfOutputPlugin - projectConfig filtering', () => { - let tempDir: string, - plugin: TestableWindsurfOutputPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-proj-config-test-')) - plugin = new TestableWindsurfOutputPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch {} - }) - - describe('registerProjectOutputFiles', () => { - it('should include all project rules when no projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [createMockProject('proj1', tempDir, 'proj1')] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).toContain('rule-test-rule2.md') - }) - - it('should filter rules by include in projectConfig', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).not.toContain('rule-test-rule2.md') - }) - - it('should filter rules by includeSeries excluding non-matching series', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).not.toContain('rule-test-rule1.md') - expect(fileNames).toContain('rule-test-rule2.md') - }) - - it('should include rules without seriName regardless of include filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', void 0, 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = collectFileNames(results) - - expect(fileNames).toContain('rule-test-rule1.md') - expect(fileNames).not.toContain('rule-test-rule2.md') - }) - - it('should filter independently for each project', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project'), - createMockRulePrompt('test', 'rule2', 'vue', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}), - createMockProject('proj2', tempDir, 'proj2', {rules: {includeSeries: ['vue']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const fileNames = results.map(r => ({ - path: r.path, - fileName: r.path.split(/[/\\]/).pop() - })) - - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule1.md')).toBe(true) - expect(fileNames.some(f => f.path.includes('proj1') && f.fileName === 'rule-test-rule2.md')).toBe(false) - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule2.md')).toBe(true) - expect(fileNames.some(f => f.path.includes('proj2') && f.fileName === 'rule-test-rule1.md')).toBe(false) - }) - - it('should return empty when include matches nothing', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputFiles(ctx) - const ruleFiles = results.filter(r => r.path.includes('rule-')) - - expect(ruleFiles).toHaveLength(0) - }) - }) - - describe('registerProjectOutputDirs', () => { - it('should not register rules dir when all rules filtered out', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['react']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputDirs(ctx) - const rulesDirs = results.filter(r => r.path.includes('rules')) - - expect(rulesDirs).toHaveLength(0) - }) - - it('should register rules dir when rules match filter', async () => { - const rules = [ - createMockRulePrompt('test', 'rule1', 'uniapp', 'project') - ] - const projects = [ - createMockProject('proj1', tempDir, 'proj1', {rules: {includeSeries: ['uniapp']}}) - ] - const ctx = createMockContext(tempDir, rules, projects) - - const results = await plugin.registerProjectOutputDirs(ctx) - const rulesDirs = results.filter(r => r.path.includes('rules')) - - expect(rulesDirs.length).toBeGreaterThan(0) - }) - }) -}) diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.property.test.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.property.test.ts deleted file mode 100644 index ad133594..00000000 --- a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.property.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -import type {OutputPluginContext, OutputWriteContext, RelativePath} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {createLogger, FilePathKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {describe, it} from 'vitest' -import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -class TestableWindsurfOutputPlugin extends WindsurfOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -const validNameGen = fc.string({minLength: 1, maxLength: 20, unit: 'grapheme-ascii'}) // Generators for property-based tests - .filter(s => /^[\w-]+$/.test(s)) - .map(s => s.toLowerCase()) - -const skillNameGen = validNameGen.filter(name => name.length > 0 && name !== 'create-rule' && name !== 'create-skill') - -const commandNameGen = validNameGen.filter(name => name.length > 0) - -const seriesNameGen = fc.option(validNameGen, {nil: void 0}) - -const fileContentGen = fc.string({minLength: 0, maxLength: 500}) - -describe('windsurf output plugin property tests', () => { - describe('registerGlobalOutputDirs', () => { - it('should always return empty array when no inputs provided', async () => { - await fc.assert( - fc.asyncProperty(fc.string({minLength: 1}), async _basePath => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return results.length === 0 - }) - ) - }) - - it('should always register at least one dir when fastCommands exist', async () => { - await fc.assert( - fc.asyncProperty( - commandNameGen, - seriesNameGen, - async (commandName, series) => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const fastCommand = { - type: PromptKind.FastCommand, - commandName, - series, - content: 'Test content', - length: 12, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir), - markdownContents: [], - yamlFrontMatter: {description: 'Test command', namingCase: 'kebab-case'} - } - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - fastCommands: [fastCommand], - skills: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return results.length >= 1 && results.some(r => r.path === 'global_workflows') - } - ) - ) - }) - - it('should always register at least one dir when skills exist', async () => { - await fc.assert( - fc.asyncProperty( - skillNameGen, - async skillName => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const skill = { - yamlFrontMatter: {name: skillName, description: 'Test skill', namingCase: 'kebab-case'}, - dir: createMockRelativePath(skillName, tempDir), - content: '# Test Skill', - length: 12, - type: PromptKind.Skill, - filePathKind: FilePathKind.Relative, - markdownContents: [] - } - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [skill], - fastCommands: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return results.length >= 1 && results.some(r => r.path.startsWith('skills')) - } - ) - ) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should always return empty array when no inputs provided', async () => { - await fc.assert( - fc.asyncProperty(fc.string({minLength: 1}), async _basePath => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return results.length === 0 - }) - ) - }) - - it('should register one file per fastCommand', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(commandNameGen, {minLength: 1, maxLength: 5}), - async commandNames => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const fastCommands = commandNames.map(name => ({ - type: PromptKind.FastCommand, - commandName: name, - content: 'Test content', - length: 12, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir), - markdownContents: [], - yamlFrontMatter: {description: 'Test command', namingCase: 'kebab-case'} - })) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - fastCommands, - skills: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - const workflowFiles = results.filter(r => r.path.startsWith('global_workflows')) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return workflowFiles.length === commandNames.length - } - ) - ) - }) - }) - - describe('canWrite', () => { - it('should return true when any content exists', async () => { - await fc.assert( - fc.asyncProperty( - fc.boolean(), - fc.boolean(), - fc.boolean(), - async (hasSkills, hasFastCommands, hasGlobalMemory) => { - if (!hasSkills && !hasFastCommands && !hasGlobalMemory) return true // Skip if all are false - - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: hasSkills - ? [{yamlFrontMatter: {name: 'test-skill', description: 'Test', namingCase: 'kebab-case'}}] - : [], - fastCommands: hasFastCommands - ? [{commandName: 'test', yamlFrontMatter: {description: 'Test', namingCase: 'kebab-case'}}] - : [], - globalMemory: hasGlobalMemory - ? {content: 'Global rules', length: 12, type: PromptKind.GlobalMemory} - : null - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return result - } - ) - ) - }) - }) - - describe('writeGlobalOutputs dry-run property', () => { - it('should not modify filesystem when dryRun is true', async () => { - await fc.assert( - fc.asyncProperty( - fileContentGen, - async content => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const initialFiles = fs.existsSync(tempDir) // Capture initial state - ? fs.readdirSync(tempDir) - : [] - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: { - type: PromptKind.GlobalMemory, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir), - markdownContents: [] - }, - skills: [], - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: true - } as unknown as OutputWriteContext - - await plugin.writeGlobalOutputs(ctx) - - const finalFiles = fs.existsSync(tempDir) // Verify filesystem unchanged - ? fs.readdirSync(tempDir) - : [] - - fs.rmSync(tempDir, {recursive: true, force: true}) - return JSON.stringify(initialFiles) === JSON.stringify(finalFiles) - } - ) - ) - }) - }) - - describe('writeProjectOutputs', () => { - it('should always return empty results regardless of input', async () => { - await fc.assert( - fc.asyncProperty( - fc.boolean(), - fc.boolean(), - async (hasProjects, hasGlobalMemory) => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const projects = hasProjects - ? [{name: 'project-a', dirFromWorkspacePath: createMockRelativePath('project-a', tempDir)}] - : [] - - const ctx = { - collectedInputContext: { - workspace: {projects, directory: createMockRelativePath('.', tempDir)}, - globalMemory: hasGlobalMemory - ? {content: 'Global rules', length: 12, type: PromptKind.GlobalMemory} - : null - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeProjectOutputs(ctx) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return results.files.length === 0 && results.dirs.length === 0 - } - ) - ) - }) - }) - - describe('output path consistency', () => { - it('should generate consistent base paths for all outputs', async () => { - await fc.assert( - fc.asyncProperty( - skillNameGen, - commandNameGen, - async (skillName, commandName) => { - const plugin = new TestableWindsurfOutputPlugin() - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-prop-')) - plugin.setMockHomeDir(tempDir) - - const skill = { - yamlFrontMatter: {name: skillName, description: 'Test skill', namingCase: 'kebab-case'}, - dir: createMockRelativePath(skillName, tempDir), - content: '# Test Skill', - length: 12, - type: PromptKind.Skill, - filePathKind: FilePathKind.Relative, - markdownContents: [] - } - - const fastCommand = { - type: PromptKind.FastCommand, - commandName, - content: 'Test content', - length: 12, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', tempDir), - markdownContents: [], - yamlFrontMatter: {description: 'Test command', namingCase: 'kebab-case'} - } - - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [skill], - fastCommands: [fastCommand] - } - } as unknown as OutputPluginContext - - const dirs = await plugin.registerGlobalOutputDirs(ctx) - const files = await plugin.registerGlobalOutputFiles(ctx) - - const basePaths = [...dirs, ...files].map(r => r.basePath) - const allSameBase = basePaths.every(bp => bp === basePaths[0]) - - fs.rmSync(tempDir, {recursive: true, force: true}) - return allSameBase && basePaths[0].includes('.codeium') - } - ) - ) - }) - }) -}) diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.test.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.test.ts deleted file mode 100644 index 40be0eb5..00000000 --- a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.test.ts +++ /dev/null @@ -1,677 +0,0 @@ -import type { - FastCommandPrompt, - GlobalMemoryPrompt, - OutputPluginContext, - OutputWriteContext, - RelativePath, - RulePrompt, - SkillPrompt -} from '@truenine/plugin-shared' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import {createLogger, FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import {afterEach, beforeEach, describe, expect, it} from 'vitest' -import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' - -function createMockRelativePath(pathStr: string, basePath: string): RelativePath { - return { - pathKind: FilePathKind.Relative, - path: pathStr, - basePath, - getDirectoryName: () => pathStr, - getAbsolutePath: () => path.join(basePath, pathStr) - } -} - -function createMockRulePrompt( - series: string, - ruleName: string, - globs: readonly string[], - scope: 'global' | 'project', - seriName?: string -): RulePrompt { - const content = '# Rule body\n\nFollow this rule.' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', ''), - markdownContents: [], - yamlFrontMatter: { - description: 'Rule description', - globs, - namingCase: NamingCaseKind.KebabCase - }, - series, - ruleName, - globs, - scope, - ...seriName != null && {seriName} - } as RulePrompt -} - -function createMockGlobalMemoryPrompt(content: string, basePath: string): GlobalMemoryPrompt { - return { - type: PromptKind.GlobalMemory, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', basePath), - markdownContents: [], - parentDirectoryPath: { - type: 'UserHome', - directory: createMockRelativePath('.codeium/windsurf', basePath) - } - } as GlobalMemoryPrompt -} - -function createMockFastCommandPrompt( - commandName: string, - series?: string, - basePath = '' -): FastCommandPrompt { - const content = 'Run something' - return { - type: PromptKind.FastCommand, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: createMockRelativePath('.', basePath), - markdownContents: [], - yamlFrontMatter: {description: 'Fast command', namingCase: NamingCaseKind.KebabCase}, - ...series != null && {series}, - commandName - } as FastCommandPrompt -} - -function createMockSkillPrompt( - name: string, - content = '# Skill', - basePath = '', - options?: {childDocs?: {relativePath: string, content: unknown}[], resources?: {relativePath: string, content: string, encoding: 'text' | 'base64'}[]} -): SkillPrompt { - return { - yamlFrontMatter: {name, description: 'A skill', namingCase: NamingCaseKind.KebabCase}, - dir: createMockRelativePath(name, basePath), - content, - length: content.length, - type: PromptKind.Skill, - filePathKind: FilePathKind.Relative, - markdownContents: [], - ...options - } as unknown as SkillPrompt -} - -class TestableWindsurfOutputPlugin extends WindsurfOutputPlugin { - private mockHomeDir: string | null = null - - public setMockHomeDir(dir: string | null): void { - this.mockHomeDir = dir - } - - protected override getHomeDir(): string { - if (this.mockHomeDir != null) return this.mockHomeDir - return super.getHomeDir() - } -} - -describe('windsurf output plugin', () => { - let tempDir: string, plugin: TestableWindsurfOutputPlugin - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'windsurf-test-')) - plugin = new TestableWindsurfOutputPlugin() - plugin.setMockHomeDir(tempDir) - }) - - afterEach(() => { - if (tempDir != null && fs.existsSync(tempDir)) { - try { - fs.rmSync(tempDir, {recursive: true, force: true}) - } - catch { - } // ignore cleanup errors - } - }) - - describe('constructor', () => { - it('should have correct plugin name', () => expect(plugin.name).toBe('WindsurfOutputPlugin')) - - it('should depend on AgentsOutputPlugin', () => expect(plugin.dependsOn).toContain('AgentsOutputPlugin')) - }) - - describe('registerGlobalOutputDirs', () => { - it('should return empty when no fastCommands and no skills', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(0) - }) - - it('should register global_workflows dir when fastCommands exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [createMockFastCommandPrompt('compile', void 0, tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(1) - expect(results[0]?.path).toBe('global_workflows') - expect(results[0]?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'global_workflows')) - }) - - it('should register skills/ dir when skills exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('custom-skill', '# Skill', tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(1) - expect(results[0]?.path).toBe(path.join('skills', 'custom-skill')) - expect(results[0]?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'skills', 'custom-skill')) - }) - - it('should register both workflows and skills dirs when both exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('skill-a', '# Skill', tempDir)], - fastCommands: [createMockFastCommandPrompt('compile', void 0, tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputDirs(ctx) - expect(results).toHaveLength(2) - const paths = results.map(r => r.path) - expect(paths).toContain('global_workflows') - expect(paths).toContain(path.join('skills', 'skill-a')) - }) - }) - - describe('registerGlobalOutputFiles', () => { - it('should return empty when no fastCommands and no skills', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results).toHaveLength(0) - }) - - it('should register workflow files under global_workflows/ when fastCommands exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [ - createMockFastCommandPrompt('compile', 'build', tempDir), - createMockFastCommandPrompt('test', void 0, tempDir) - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.length).toBeGreaterThanOrEqual(2) - const paths = results.map(r => r.path) - expect(paths).toContain(path.join('global_workflows', 'build-compile.md')) - expect(paths).toContain(path.join('global_workflows', 'test.md')) - const compileEntry = results.find(r => r.path.includes('build-compile')) - expect(compileEntry?.getAbsolutePath()).toBe(path.join(tempDir, '.codeium', 'windsurf', 'global_workflows', 'build-compile.md')) - }) - - it('should register skill files under skills//SKILL.md when skills exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('my-skill', '# Skill', tempDir)] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.some(r => r.path === path.join('skills', 'my-skill', 'SKILL.md'))).toBe(true) - }) - - it('should register childDocs when skills have them', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('my-skill', '# Skill', tempDir, { - childDocs: [{relativePath: 'doc.cn.mdx', content: '# Child Doc'}] - }) - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.some(r => r.path === path.join('skills', 'my-skill', 'doc.cn.md'))).toBe(true) - }) - - it('should register resources when skills have them', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('my-skill', '# Skill', tempDir, { - resources: [{relativePath: 'resource.json', content: '{}', encoding: 'text'}] - }) - ] - } - } as unknown as OutputPluginContext - - const results = await plugin.registerGlobalOutputFiles(ctx) - expect(results.some(r => r.path === path.join('skills', 'my-skill', 'resource.json'))).toBe(true) - }) - }) - - describe('canWrite', () => { - it('should return true when skills exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [{yamlFrontMatter: {name: 's'}, dir: createMockRelativePath('s', tempDir)}] - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return false when no skills and no fastCommands and no globalMemory', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [], - globalMemory: null - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(false) - }) - - it('should return true when only fastCommands exist', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [createMockFastCommandPrompt('lint', void 0, tempDir)], - globalMemory: null - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return true when only globalMemory exists', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [], - globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return true when .codeiumignore exists in aiAgentIgnoreConfigFiles', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [], - globalMemory: null, - rules: [], - aiAgentIgnoreConfigFiles: [{fileName: '.codeiumignore', content: 'node_modules/'}] - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(true) - }) - - it('should return false when only .codeignore (wrong name) exists in aiAgentIgnoreConfigFiles', async () => { // @see https://docs.windsurf.com/context-awareness/windsurf-ignore#windsurf-ignore - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [], - globalMemory: null, - rules: [], - aiAgentIgnoreConfigFiles: [{fileName: '.codeignore', content: 'node_modules/'}] - } - } as unknown as OutputWriteContext - - const result = await plugin.canWrite(ctx) - expect(result).toBe(false) - }) - }) - - describe('writeGlobalOutputs', () => { - it('should write global memory to ~/.codeium/windsurf/memories/global_rules.md', async () => { - const globalContent = '# Global Rules\n\nAlways apply these rules.' - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: createMockGlobalMemoryPrompt(globalContent, tempDir), - skills: [], - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files.length).toBeGreaterThanOrEqual(1) - expect(results.files[0]?.success).toBe(true) - - const memoryPath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'global_rules.md') - expect(fs.existsSync(memoryPath)).toBe(true) - const content = fs.readFileSync(memoryPath, 'utf8') - expect(content).toContain(globalContent) - }) - - it('should write fast command files to ~/.codeium/windsurf/global_workflows/', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [ - createMockFastCommandPrompt('compile', 'build', tempDir), - createMockFastCommandPrompt('test', void 0, tempDir) - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files).toHaveLength(2) - - const workflowsDir = path.join(tempDir, '.codeium', 'windsurf', 'global_workflows') - expect(fs.existsSync(workflowsDir)).toBe(true) - - const buildCompilePath = path.join(workflowsDir, 'build-compile.md') - const testPath = path.join(workflowsDir, 'test.md') - expect(fs.existsSync(buildCompilePath)).toBe(true) - expect(fs.existsSync(testPath)).toBe(true) - - const buildCompileContent = fs.readFileSync(buildCompilePath, 'utf8') - expect(buildCompileContent).toContain('Run something') - }) - - it('should write skill to ~/.codeium/windsurf/skills//SKILL.md', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('my-skill', '# My Skill Content', tempDir)], - globalMemory: null, - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files.length).toBeGreaterThanOrEqual(1) - expect(results.files.every(f => f.success)).toBe(true) - - const skillPath = path.join(tempDir, '.codeium', 'windsurf', 'skills', 'my-skill', 'SKILL.md') - expect(fs.existsSync(skillPath)).toBe(true) - const content = fs.readFileSync(skillPath, 'utf8') - expect(content).toContain('name: my-skill') - expect(content).toContain('# My Skill Content') - }) - - it('should write childDocs when skills have them', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('my-skill', '# Skill', tempDir, { - childDocs: [{relativePath: 'guide.cn.mdx', content: '# Guide Content'}] - }) - ], - globalMemory: null, - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - await plugin.writeGlobalOutputs(ctx) - - const childDocPath = path.join(tempDir, '.codeium', 'windsurf', 'skills', 'my-skill', 'guide.cn.md') - expect(fs.existsSync(childDocPath)).toBe(true) - const content = fs.readFileSync(childDocPath, 'utf8') - expect(content).toContain('# Guide Content') - }) - - it('should write resources when skills have them', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [ - createMockSkillPrompt('my-skill', '# Skill', tempDir, { - resources: [{relativePath: 'schema.json', content: '{"type": "object"}', encoding: 'text'}] - }) - ], - globalMemory: null, - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - await plugin.writeGlobalOutputs(ctx) - - const resourcePath = path.join(tempDir, '.codeium', 'windsurf', 'skills', 'my-skill', 'schema.json') - expect(fs.existsSync(resourcePath)).toBe(true) - const content = fs.readFileSync(resourcePath, 'utf8') - expect(content).toContain('{"type": "object"}') - }) - - it('should not write files on dryRun', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir), - skills: [], - fastCommands: [] - }, - logger: createLogger('test', 'debug'), - dryRun: true - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files.length).toBeGreaterThanOrEqual(1) - expect(results.files[0]?.success).toBe(true) - - const memoryPath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'global_rules.md') - expect(fs.existsSync(memoryPath)).toBe(false) - }) - - it('should write global rule files with trigger/globs frontmatter', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [], - fastCommands: [], - rules: [ - createMockRulePrompt('test', 'glob', ['src/**/*.ts', '**/*.tsx'], 'global') - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeGlobalOutputs(ctx) - expect(results.files).toHaveLength(1) - - const rulePath = path.join(tempDir, '.codeium', 'windsurf', 'memories', 'rule-test-glob.md') - expect(fs.existsSync(rulePath)).toBe(true) - - const content = fs.readFileSync(rulePath, 'utf8') - expect(content).toContain('trigger: glob') - expect(content).toContain('globs: src/**/*.ts, **/*.tsx') - expect(content).not.toContain('globs: "src/**/*.ts, **/*.tsx"') - expect(content).toContain('Follow this rule.') - }) - }) - - describe('writeProjectOutputs', () => { - it('should return empty results when no project rules', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - globalMemory: createMockGlobalMemoryPrompt('Global rules', tempDir) - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeProjectOutputs(ctx) - expect(results.files).toHaveLength(0) - expect(results.dirs).toHaveLength(0) - }) - - it('should write .codeiumignore to project directories', async () => { - const projectDir = path.join(tempDir, 'my-project') - fs.mkdirSync(projectDir, {recursive: true}) - - const ctx = { - collectedInputContext: { - workspace: { - projects: [ - { - name: 'my-project', - dirFromWorkspacePath: createMockRelativePath('my-project', tempDir) - } - ], - directory: createMockRelativePath('.', tempDir) - }, - rules: [], - aiAgentIgnoreConfigFiles: [{fileName: '.codeiumignore', content: 'node_modules/\n.env\ndist/'}] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeProjectOutputs(ctx) - - const ignorePath = path.join(tempDir, 'my-project', '.codeiumignore') - expect(fs.existsSync(ignorePath)).toBe(true) - const content = fs.readFileSync(ignorePath, 'utf8') - expect(content).toContain('node_modules/') - expect(results.files.some(f => f.success)).toBe(true) - }) - - it('should not write .codeignore (wrong name) to project directories', async () => { - const projectDir = path.join(tempDir, 'my-project') - fs.mkdirSync(projectDir, {recursive: true}) - - const ctx = { - collectedInputContext: { - workspace: { - projects: [ - { - name: 'my-project', - dirFromWorkspacePath: createMockRelativePath('my-project', tempDir) - } - ], - directory: createMockRelativePath('.', tempDir) - }, - rules: [], - aiAgentIgnoreConfigFiles: [{fileName: '.codeignore', content: 'node_modules/'}] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - await plugin.writeProjectOutputs(ctx) - - const wrongIgnorePath = path.join(tempDir, 'my-project', '.codeignore') - const correctIgnorePath = path.join(tempDir, 'my-project', '.codeiumignore') - expect(fs.existsSync(wrongIgnorePath)).toBe(false) - expect(fs.existsSync(correctIgnorePath)).toBe(false) - }) - - it('should write project rules and apply seriName include filter from projectConfig', async () => { - const ctx = { - collectedInputContext: { - workspace: { - projects: [ - { - name: 'proj1', - dirFromWorkspacePath: createMockRelativePath('proj1', tempDir), - projectConfig: {rules: {includeSeries: ['uniapp']}} - } - ], - directory: createMockRelativePath('.', tempDir) - }, - rules: [ - createMockRulePrompt('test', 'uniapp-only', ['src/**/*.vue'], 'project', 'uniapp'), - createMockRulePrompt('test', 'vue-only', ['src/**/*.ts'], 'project', 'vue') - ] - }, - logger: createLogger('test', 'debug'), - dryRun: false - } as unknown as OutputWriteContext - - const results = await plugin.writeProjectOutputs(ctx) - const outputPaths = results.files.map(file => file.path.path.replaceAll('\\', '/')) - - expect(outputPaths.some(p => p.endsWith('rule-test-uniapp-only.md'))).toBe(true) - expect(outputPaths.some(p => p.endsWith('rule-test-vue-only.md'))).toBe(false) - - const includedRulePath = path.join(tempDir, 'proj1', '.windsurf', 'rules', 'rule-test-uniapp-only.md') - const excludedRulePath = path.join(tempDir, 'proj1', '.windsurf', 'rules', 'rule-test-vue-only.md') - - expect(fs.existsSync(includedRulePath)).toBe(true) - expect(fs.existsSync(excludedRulePath)).toBe(false) - - const includedRuleContent = fs.readFileSync(includedRulePath, 'utf8') - expect(includedRuleContent).toContain('trigger: glob') - expect(includedRuleContent).toContain('globs: src/**/*.vue') - }) - }) - - describe('clean support', () => { - it('should register global output dirs for cleanup', async () => { - const ctx = { - collectedInputContext: { - workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, - skills: [createMockSkillPrompt('my-skill', '# Skill', tempDir)], - fastCommands: [createMockFastCommandPrompt('test', void 0, tempDir)] - } - } as unknown as OutputPluginContext - - const dirs = await plugin.registerGlobalOutputDirs(ctx) - expect(dirs.length).toBe(2) - - const files = await plugin.registerGlobalOutputFiles(ctx) - expect(files.length).toBeGreaterThanOrEqual(2) - }) - }) -}) diff --git a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts index 0e140e9f..dc3bace2 100644 --- a/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts +++ b/cli/src/plugins/plugin-windsurf/WindsurfOutputPlugin.ts @@ -1,19 +1,20 @@ +import type {RuleContentOptions} from '@truenine/plugin-output-shared' import type { - FastCommandPrompt, + CommandPrompt, OutputPluginContext, OutputWriteContext, RulePrompt, SkillPrompt, WriteResult, WriteResults -} from '@truenine/plugin-shared' -import type {RelativePath} from '@truenine/plugin-shared/types' +} from '../plugin-shared' +import type {RelativePath} from '../plugin-shared/types' import {Buffer} from 'node:buffer' import * as fs from 'node:fs' import * as path from 'node:path' import {buildMarkdownWithFrontMatter} from '@truenine/md-compiler/markdown' import {AbstractOutputPlugin, applySubSeriesGlobPrefix, filterCommandsByProjectConfig, filterRulesByProjectConfig, filterSkillsByProjectConfig} from '@truenine/plugin-output-shared' -import {FilePathKind, PLUGIN_NAMES} from '@truenine/plugin-shared' +import {FilePathKind, PLUGIN_NAMES} from '../plugin-shared' const CODEIUM_WINDSURF_DIR = '.codeium/windsurf' const WORKFLOWS_SUBDIR = 'global_workflows' @@ -37,11 +38,11 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise { const results: RelativePath[] = [] - 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 workflowsDir = this.getGlobalWorkflowsDir() results.push({pathKind: FilePathKind.Relative, path: WORKFLOWS_SUBDIR, basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => WORKFLOWS_SUBDIR, getAbsolutePath: () => workflowsDir}) @@ -68,15 +69,15 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { async registerGlobalOutputFiles(ctx: OutputPluginContext): Promise { const results: RelativePath[] = [] - const {skills, fastCommands} = ctx.collectedInputContext + const {skills, commands} = 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) const workflowsDir = this.getGlobalWorkflowsDir() 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(workflowsDir, fileName) results.push({pathKind: FilePathKind.Relative, path: path.join(WORKFLOWS_SUBDIR, fileName), basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => WORKFLOWS_SUBDIR, getAbsolutePath: () => fullPath}) } @@ -118,21 +119,21 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { } async canWrite(ctx: OutputWriteContext): Promise { - const {skills, fastCommands, globalMemory, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext + const {skills, commands, globalMemory, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext const hasSkills = (skills?.length ?? 0) > 0 - const hasFastCommands = (fastCommands?.length ?? 0) > 0 + const hasCommands = (commands?.length ?? 0) > 0 const hasRules = (rules?.length ?? 0) > 0 const hasGlobalMemory = globalMemory != null const hasCodeIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.codeiumignore') ?? false - if (hasSkills || hasFastCommands || hasGlobalMemory || hasRules || hasCodeIgnore) return true + if (hasSkills || hasCommands || hasGlobalMemory || hasRules || hasCodeIgnore) return true this.log.trace({action: 'skip', reason: 'noOutputs'}) return false } async writeGlobalOutputs(ctx: OutputWriteContext): Promise { - const {skills, fastCommands, globalMemory, rules} = ctx.collectedInputContext + const {skills, commands, globalMemory, rules} = ctx.collectedInputContext const projectConfig = this.resolvePromptSourceProjectConfig(ctx) const fileResults: WriteResult[] = [] const dirResults: WriteResult[] = [] @@ -145,8 +146,8 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { for (const skill of filteredSkills) fileResults.push(...await this.writeGlobalSkill(ctx, skillsDir, skill)) } - if (fastCommands != null && fastCommands.length > 0) { - const filteredCommands = filterCommandsByProjectConfig(fastCommands, projectConfig) + if (commands != null && commands.length > 0) { + const filteredCommands = filterCommandsByProjectConfig(commands, projectConfig) const workflowsDir = this.getGlobalWorkflowsDir() for (const cmd of filteredCommands) fileResults.push(await this.writeGlobalWorkflow(ctx, workflowsDir, cmd)) } @@ -241,9 +242,9 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { } } - private async writeGlobalWorkflow(ctx: OutputWriteContext, workflowsDir: string, cmd: FastCommandPrompt): Promise { + private async writeGlobalWorkflow(ctx: OutputWriteContext, workflowsDir: 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(workflowsDir, fileName) const relativePath: RelativePath = {pathKind: FilePathKind.Relative, path: path.join(WORKFLOWS_SUBDIR, fileName), basePath: this.getCodeiumWindsurfDir(), getDirectoryName: () => WORKFLOWS_SUBDIR, getAbsolutePath: () => fullPath} const content = this.buildMarkdownContentWithRaw(cmd.content, cmd.yamlFrontMatter, cmd.rawFrontMatter) @@ -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') diff --git a/cli/src/schema.property.test.ts b/cli/src/schema.property.test.ts deleted file mode 100644 index 280027bb..00000000 --- a/cli/src/schema.property.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' -import {TNMSC_JSON_SCHEMA} from './schema' - -const NUM_RUNS = 200 - -type SchemaObj = Record -type SchemaProps = Record - -const schema = TNMSC_JSON_SCHEMA as SchemaObj -const props = (schema['properties'] ?? {}) as SchemaProps - -/** - * Property-based tests for TNMSC_JSON_SCHEMA (Zod-generated). - * Validates structural invariants that must hold for any derived copy or - * serialization of the schema constant. - */ - -describe('schema property tests — JSON round-trip', () => { - it('structuredClone preserves deep equality', () => { - fc.assert( - fc.property(fc.constant(TNMSC_JSON_SCHEMA), s => { expect(structuredClone(s)).toEqual(s) }), - {numRuns: NUM_RUNS} - ) - }) - - it('pretty-printing with any indent 0–8 always produces parseable JSON', () => { - fc.assert( - fc.property(fc.integer({min: 0, max: 8}), indent => { - const pretty = JSON.stringify(TNMSC_JSON_SCHEMA, null, indent) - expect(() => JSON.parse(pretty)).not.toThrow() - const parsed = JSON.parse(pretty) as typeof TNMSC_JSON_SCHEMA - expect(parsed).toEqual(TNMSC_JSON_SCHEMA) - }), - {numRuns: 50} - ) - }) -}) - -describe('schema property tests — logLevel enum completeness', () => { - const validLevels = ['trace', 'debug', 'info', 'warn', 'error'] as const - const levels = (props['logLevel']?.['enum'] ?? []) as string[] - - it('every valid log level is present in the enum', () => { - fc.assert( - fc.property(fc.constantFrom(...validLevels), level => { expect(levels).toContain(level) }), - {numRuns: NUM_RUNS} - ) - }) - - it('no duplicate entries in logLevel enum', () => { - fc.assert( - fc.property(fc.constant(levels), ls => { - const unique = new Set(ls) - expect(unique.size).toBe(ls.length) - }), - {numRuns: 50} - ) - }) -}) - -describe('schema property tests — dirPair inline structure consistency', () => { - const dirPairFields = ['skill', 'fastCommand', 'subAgent', 'rule', 'globalMemory', 'workspaceMemory', 'project'] as const - const ssp = props['shadowSourceProject'] ?? {} - const sspProps = (ssp['properties'] ?? {}) as SchemaProps - - it('every dirPair field is defined as an object schema', () => { - fc.assert( - fc.property(fc.constantFrom(...dirPairFields), field => { - const fieldSchema = sspProps[field] - expect(fieldSchema).toBeDefined() - expect((fieldSchema as SchemaObj)?.['type']).toBe('object') - }), - {numRuns: NUM_RUNS} - ) - }) - - it('every dirPair field has src and dist properties', () => { - fc.assert( - fc.property(fc.constantFrom(...dirPairFields), field => { - const fieldProps = sspProps[field]?.['properties'] as SchemaProps | undefined - expect(fieldProps?.['src']).toBeDefined() - expect(fieldProps?.['dist']).toBeDefined() - }), - {numRuns: NUM_RUNS} - ) - }) -}) - -describe('schema property tests — top-level property presence', () => { - const requiredTopLevel = ['version', 'workspaceDir', 'logLevel', 'shadowSourceProject', 'fastCommandSeriesOptions', 'profile'] as const - - it('every expected top-level property is defined', () => { - fc.assert( - fc.property(fc.constantFrom(...requiredTopLevel), key => { expect(props[key]).toBeDefined() }), - {numRuns: NUM_RUNS} - ) - }) -}) diff --git a/cli/src/schema.test.ts b/cli/src/schema.test.ts deleted file mode 100644 index 05978e0f..00000000 --- a/cli/src/schema.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import {describe, expect, it} from 'vitest' -import {TNMSC_JSON_SCHEMA} from './schema' - -type SchemaObj = Record -type SchemaProps = Record - -const schema = TNMSC_JSON_SCHEMA as SchemaObj -const props = (schema['properties'] ?? {}) as SchemaProps - -describe('tnmsc json schema — structural invariants', () => { - it('is an object', () => expect(typeof schema).toBe('object')) - - it('has a properties object', () => expect(typeof schema['properties']).toBe('object')) - - it('root type is object', () => expect(schema['type']).toBe('object')) -}) - -describe('tnmsc json schema — top-level properties coverage', () => { - it('defines version property', () => expect(props['version']).toBeDefined()) - - it('defines workspaceDir property', () => expect(props['workspaceDir']).toBeDefined()) - - it('defines logLevel property', () => expect(props['logLevel']).toBeDefined()) - - it('defines shadowSourceProject property', () => expect(props['shadowSourceProject']).toBeDefined()) - - it('defines fastCommandSeriesOptions property', () => expect(props['fastCommandSeriesOptions']).toBeDefined()) - - it('defines profile property', () => expect(props['profile']).toBeDefined()) - - it('logLevel is string type', () => expect(props['logLevel']?.['type']).toBe('string')) - - it('logLevel enum contains all five levels', () => { - const levels = props['logLevel']?.['enum'] as string[] | undefined - expect(levels).toBeDefined() - expect(levels).toContain('trace') - expect(levels).toContain('debug') - expect(levels).toContain('info') - expect(levels).toContain('warn') - expect(levels).toContain('error') - expect(levels).toHaveLength(5) - }) - - it('shadowSourceProject is object type', () => expect(props['shadowSourceProject']?.['type']).toBe('object')) - - it('fastCommandSeriesOptions is object type', () => expect(props['fastCommandSeriesOptions']?.['type']).toBe('object')) - - it('profile is object type', () => expect(props['profile']?.['type']).toBe('object')) -}) - -describe('tnmsc json schema — shadowSourceProject sub-schema', () => { - const ssp = props['shadowSourceProject'] ?? {} - const sspProps = (ssp['properties'] ?? {}) as SchemaProps - - it('requires name field', () => { - const required = ssp['required'] as string[] | undefined - expect(required).toContain('name') - }) - - it('defines name as string', () => expect(sspProps['name']?.['type']).toBe('string')) - - const dirPairFields = ['skill', 'fastCommand', 'subAgent', 'rule', 'globalMemory', 'workspaceMemory', 'project'] as const - - for (const field of dirPairFields) { - it(`defines ${field} as object`, () => { - const fieldSchema = sspProps[field] - expect(fieldSchema).toBeDefined() - expect(fieldSchema?.['type']).toBe('object') - }) - } -}) - -describe('tnmsc json schema — dirPair inline structure', () => { - const ssp = props['shadowSourceProject'] ?? {} - const sspProps = (ssp['properties'] ?? {}) as SchemaProps - const skillProps = sspProps['skill']?.['properties'] as SchemaProps | undefined - - it('skill has src property of type string', () => expect(skillProps?.['src']?.['type']).toBe('string')) - - it('skill has dist property of type string', () => expect(skillProps?.['dist']?.['type']).toBe('string')) -}) - -describe('tnmsc json schema — fastCommandSeriesOptions sub-schema', () => { - const fcs = props['fastCommandSeriesOptions'] ?? {} - const fcsProps = (fcs['properties'] ?? {}) as SchemaProps - - it('defines includeSeriesPrefix', () => expect(fcsProps['includeSeriesPrefix']).toBeDefined()) - - it('includeSeriesPrefix is boolean type', () => expect(fcsProps['includeSeriesPrefix']?.['type']).toBe('boolean')) - - it('defines pluginOverrides', () => expect(fcsProps['pluginOverrides']).toBeDefined()) -}) - -describe('tnmsc json schema — JSON round-trip', () => { - it('serializes and deserializes without loss', () => { - const serialized = JSON.stringify(TNMSC_JSON_SCHEMA) - const deserialized = JSON.parse(serialized) - expect(deserialized).toEqual(TNMSC_JSON_SCHEMA) - }) - - it('produces valid JSON string', () => expect(() => JSON.stringify(TNMSC_JSON_SCHEMA)).not.toThrow()) - - it('pretty-printed output is non-empty', () => expect(JSON.stringify(TNMSC_JSON_SCHEMA, null, 2).length).toBeGreaterThan(100)) -}) - -describe('tnmsc json schema — profile sub-schema', () => { - const profile = props['profile'] ?? {} - const profileProps = (profile['properties'] ?? {}) as SchemaProps - - it('defines name property', () => expect(profileProps['name']).toBeDefined()) - it('defines username property', () => expect(profileProps['username']).toBeDefined()) - it('defines gender property', () => expect(profileProps['gender']).toBeDefined()) - it('defines birthday property', () => expect(profileProps['birthday']).toBeDefined()) -}) diff --git a/cli/src/schema.ts b/cli/src/schema.ts index 1f74d372..36f68f06 100644 --- a/cli/src/schema.ts +++ b/cli/src/schema.ts @@ -1,5 +1,5 @@ -import {ZUserConfigFile} from '@truenine/plugin-shared' import {zodToJsonSchema} from 'zod-to-json-schema' +import {ZUserConfigFile} from './plugins/plugin-shared' /** * JSON Schema for .tnmsc.json — auto-generated from ZUserConfigFile via zod-to-json-schema. diff --git a/cli/src/utils/EffectUtils.test.ts b/cli/src/utils/EffectUtils.test.ts deleted file mode 100644 index 56bde938..00000000 --- a/cli/src/utils/EffectUtils.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -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 {afterEach, beforeEach, describe, expect, it} from 'vitest' -import {cleanStaleDistFiles, syncDirectory} from './EffectUtils' - -describe('cleanStaleDistFiles', () => { - let tempDir: string, - srcDir: string, - distDir: string, - mockLogger: ReturnType - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-clean-stale-')) - srcDir = path.join(tempDir, 'src') - distDir = path.join(tempDir, 'dist') - fs.mkdirSync(srcDir, {recursive: true}) - fs.mkdirSync(distDir, {recursive: true}) - mockLogger = createLogger('test') - }) - - afterEach(() => fs.rmSync(tempDir, {recursive: true, force: true})) - - it('should delete dist files without corresponding src files', () => { - fs.mkdirSync(path.join(srcDir, 'skill-a')) // Create src file - fs.writeFileSync(path.join(srcDir, 'skill-a', 'skill.md'), '# Skill A') - - fs.writeFileSync(path.join(distDir, 'skill-a.md'), '# Skill A compiled') // Create dist files (one valid, one stale) - fs.writeFileSync(path.join(distDir, 'skill-b.md'), '# Skill B compiled (stale)') - - const result = cleanStaleDistFiles({fs, path, logger: mockLogger}, {srcDir, distDir, logger: mockLogger}) - - expect(result.deletedFiles).toHaveLength(1) - expect(result.deletedFiles[0]).toContain('skill-b.md') - expect(fs.existsSync(path.join(distDir, 'skill-a.md'))).toBe(true) - expect(fs.existsSync(path.join(distDir, 'skill-b.md'))).toBe(false) - }) - - it('should handle dry-run mode', () => { - fs.writeFileSync(path.join(distDir, 'stale.md'), '# Stale') - - const result = cleanStaleDistFiles({fs, path, logger: mockLogger}, {srcDir, distDir, dryRun: true, logger: mockLogger}) - - expect(result.wouldDelete).toHaveLength(1) - expect(result.deletedFiles).toHaveLength(0) - expect(fs.existsSync(path.join(distDir, 'stale.md'))).toBe(true) - }) - - it('should recursively clean subdirectories', () => { - fs.mkdirSync(path.join(srcDir, 'sub', 'skill-a'), {recursive: true}) // Create src structure - fs.writeFileSync(path.join(srcDir, 'sub', 'skill-a', 'skill.md'), '# Skill A') - - fs.mkdirSync(path.join(distDir, 'sub'), {recursive: true}) // Create dist structure with stale directory - fs.writeFileSync(path.join(distDir, 'sub', 'skill-a.md'), '# Skill A') - fs.mkdirSync(path.join(distDir, 'stale-dir'), {recursive: true}) - fs.writeFileSync(path.join(distDir, 'stale-dir', 'file.md'), '# Stale') - - const result = cleanStaleDistFiles({fs, path, logger: mockLogger}, {srcDir, distDir, logger: mockLogger}) - - expect(result.deletedFiles.some(f => f.includes('stale-dir'))).toBe(true) - expect(fs.existsSync(path.join(distDir, 'stale-dir'))).toBe(false) - }) - - it('should return empty result when dist directory does not exist', () => { - fs.rmSync(distDir, {recursive: true}) - - const result = cleanStaleDistFiles({fs, path, logger: mockLogger}, {srcDir, distDir, logger: mockLogger}) - - expect(result.deletedFiles).toHaveLength(0) - expect(result.wouldDelete).toHaveLength(0) - expect(result.errors).toHaveLength(0) - }) -}) - -describe('syncDirectory', () => { - let tempDir: string, - srcDir: string, - targetDir: string, - mockLogger: ReturnType - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-sync-')) - srcDir = path.join(tempDir, 'src') - targetDir = path.join(tempDir, 'target') - fs.mkdirSync(srcDir, {recursive: true}) - mockLogger = createLogger('test') - }) - - afterEach(() => fs.rmSync(tempDir, {recursive: true, force: true})) - - it('should copy files from src to target', () => { - fs.writeFileSync(path.join(srcDir, 'file1.md'), '# File 1') - fs.writeFileSync(path.join(srcDir, 'file2.md'), '# File 2') - - const result = syncDirectory({fs, path, logger: mockLogger}, {srcDir, targetDir, logger: mockLogger}) - - expect(result.copiedFiles).toHaveLength(2) - expect(fs.existsSync(path.join(targetDir, 'file1.md'))).toBe(true) - expect(fs.existsSync(path.join(targetDir, 'file2.md'))).toBe(true) - }) - - it('should delete orphaned files when deleteOrphans is true', () => { - fs.writeFileSync(path.join(srcDir, 'keep.md'), '# Keep') - fs.mkdirSync(targetDir, {recursive: true}) - fs.writeFileSync(path.join(targetDir, 'keep.md'), '# Old Keep') - fs.writeFileSync(path.join(targetDir, 'orphan.md'), '# Orphan') - - const result = syncDirectory({fs, path, logger: mockLogger}, {srcDir, targetDir, deleteOrphans: true, logger: mockLogger}) - - expect(result.deletedFiles).toHaveLength(1) - expect(result.deletedFiles[0]).toContain('orphan.md') - expect(fs.existsSync(path.join(targetDir, 'keep.md'))).toBe(true) - expect(fs.existsSync(path.join(targetDir, 'orphan.md'))).toBe(false) - }) - - it('should handle dry-run mode', () => { - fs.writeFileSync(path.join(srcDir, 'file.md'), '# File') - - const result = syncDirectory({fs, path, logger: mockLogger}, {srcDir, targetDir, dryRun: true, logger: mockLogger}) - - expect(result.copiedFiles).toHaveLength(1) - expect(fs.existsSync(targetDir)).toBe(false) - }) - - it('should recursively sync subdirectories', () => { - fs.mkdirSync(path.join(srcDir, 'sub'), {recursive: true}) - fs.writeFileSync(path.join(srcDir, 'sub', 'nested.md'), '# Nested') - - const result = syncDirectory({fs, path, logger: mockLogger}, {srcDir, targetDir, logger: mockLogger}) - - expect(result.copiedFiles.some(f => f.includes('nested.md'))).toBe(true) - expect(fs.existsSync(path.join(targetDir, 'sub', 'nested.md'))).toBe(true) - }) -}) diff --git a/cli/src/utils/EffectUtils.ts b/cli/src/utils/EffectUtils.ts index 81c43869..a98bdaf7 100644 --- a/cli/src/utils/EffectUtils.ts +++ b/cli/src/utils/EffectUtils.ts @@ -1,5 +1,5 @@ -import type {ILogger, InputEffectContext} from '@truenine/plugin-shared' import type {Buffer} from 'node:buffer' +import type {ILogger, InputEffectContext} from '../plugins/plugin-shared' import process from 'node:process' /** diff --git a/cli/src/utils/RelativePathFactory.test.ts b/cli/src/utils/RelativePathFactory.test.ts deleted file mode 100644 index 881f2a52..00000000 --- a/cli/src/utils/RelativePathFactory.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import {FilePathKind} from '@truenine/plugin-shared' -import {describe, expect, it} from 'vitest' -import { - createFileRelativePath, - createRelativePath, - createRelativePathWithDirName, - createSubdirRelativePath -} from './RelativePathFactory' - -describe('relativePathFactory', () => { - describe('createRelativePath', () => { - it('should create a RelativePath with correct properties', () => { - const rp = createRelativePath({pathStr: 'skills/docker', basePath: '/home/user/.gemini'}) - - expect(rp.pathKind).toBe(FilePathKind.Relative) - expect(rp.path).toBe('skills/docker') - expect(rp.basePath).toBe('/home/user/.gemini') - }) - - it('should return correct directory name', () => { - const rp = createRelativePath({pathStr: 'skills/docker', basePath: '/home/user/.gemini'}) - - expect(rp.getDirectoryName()).toBe('skills') - }) - - it('should return correct absolute path', () => { - const rp = createRelativePath({pathStr: 'skills/docker', basePath: '/home/user/.gemini'}) - - expect(rp.getAbsolutePath()).toContain('skills') - expect(rp.getAbsolutePath()).toContain('docker') - }) - }) - - describe('createRelativePathWithDirName', () => { - it('should use custom directory name', () => { - const rp = createRelativePathWithDirName({ - pathStr: 'commands/build.md', - basePath: '/project/.claude', - dirName: 'build' - }) - - expect(rp.getDirectoryName()).toBe('build') - expect(rp.path).toBe('commands/build.md') - }) - }) - - describe('createFileRelativePath', () => { - it('should create file path within directory', () => { - const dir = createRelativePath({pathStr: 'skills/docker', basePath: '/home/user/.gemini'}) - const file = createFileRelativePath(dir, 'SKILL.md') - - expect(file.path).toContain('SKILL.md') - expect(file.getDirectoryName()).toBe('skills') - expect(file.getAbsolutePath()).toContain('SKILL.md') - }) - }) - - describe('createSubdirRelativePath', () => { - it('should create subdirectory path', () => { - const parent = createRelativePath({pathStr: '.claude', basePath: '/home/user'}) - const subdir = createSubdirRelativePath(parent, 'commands') - - expect(subdir.path).toContain('commands') - expect(subdir.getDirectoryName()).toBe('commands') - expect(subdir.basePath).toBe('/home/user') - }) - }) -}) diff --git a/cli/src/utils/ResourceUtils.test.ts b/cli/src/utils/ResourceUtils.test.ts deleted file mode 100644 index 1361eb8b..00000000 --- a/cli/src/utils/ResourceUtils.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - SKILL_RESOURCE_BINARY_EXTENSIONS, - SKILL_RESOURCE_TEXT_EXTENSIONS -} from '@truenine/plugin-shared' -import {describe, expect, it} from 'vitest' -import { - getMimeType, - getResourceCategory, - isBinaryResourceExtension -} from './ResourceUtils' - -describe('isBinaryResourceExtension', () => { - it('should return true for binary extensions', () => { - const binaryExtensions = ['.docx', '.pdf', '.png', '.jpg', '.zip', '.exe'] - for (const ext of binaryExtensions) expect(isBinaryResourceExtension(ext)).toBe(true) - }) - - it('should return false for text extensions', () => { - const textExtensions = ['.kt', '.java', '.py', '.ts', '.txt'] - for (const ext of textExtensions) expect(isBinaryResourceExtension(ext)).toBe(false) - }) - - it('should be case-insensitive', () => { - expect(isBinaryResourceExtension('.PNG')).toBe(true) - expect(isBinaryResourceExtension('.Docx')).toBe(true) - }) -}) - -describe('getResourceCategory', () => { - it('should categorize image files', () => { - expect(getResourceCategory('.png')).toBe('image') - expect(getResourceCategory('.jpg')).toBe('image') - expect(getResourceCategory('.gif')).toBe('image') - expect(getResourceCategory('.svg')).toBe('image') - expect(getResourceCategory('.webp')).toBe('image') - }) - - it('should categorize code files', () => { - expect(getResourceCategory('.kt')).toBe('code') - expect(getResourceCategory('.java')).toBe('code') - expect(getResourceCategory('.py')).toBe('code') - expect(getResourceCategory('.ts')).toBe('code') - expect(getResourceCategory('.go')).toBe('code') - expect(getResourceCategory('.rs')).toBe('code') - }) - - it('should categorize data files', () => { - expect(getResourceCategory('.sql')).toBe('data') - expect(getResourceCategory('.json')).toBe('data') - expect(getResourceCategory('.xml')).toBe('data') - expect(getResourceCategory('.yaml')).toBe('data') - expect(getResourceCategory('.csv')).toBe('data') - }) - - it('should categorize document files', () => { - expect(getResourceCategory('.txt')).toBe('document') - expect(getResourceCategory('.docx')).toBe('document') - expect(getResourceCategory('.pdf')).toBe('document') - }) - - it('should categorize config files', () => { - expect(getResourceCategory('.ini')).toBe('config') - expect(getResourceCategory('.conf')).toBe('config') - expect(getResourceCategory('.env')).toBe('config') - expect(getResourceCategory('.gitignore')).toBe('config') - }) - - it('should categorize script files', () => { - expect(getResourceCategory('.sh')).toBe('script') - expect(getResourceCategory('.bash')).toBe('script') - expect(getResourceCategory('.ps1')).toBe('script') - expect(getResourceCategory('.bat')).toBe('script') - }) - - it('should categorize binary files', () => { - expect(getResourceCategory('.exe')).toBe('binary') - expect(getResourceCategory('.dll')).toBe('binary') - expect(getResourceCategory('.wasm')).toBe('binary') - expect(getResourceCategory('.zip')).toBe('binary') - }) - - it('should return other for unknown extensions', () => { - expect(getResourceCategory('.xyz')).toBe('other') - expect(getResourceCategory('.unknown')).toBe('other') - }) -}) - -describe('getMimeType', () => { - it('should return correct MIME types for known extensions', () => { - expect(getMimeType('.ts')).toBe('text/typescript') - expect(getMimeType('.js')).toBe('text/javascript') - expect(getMimeType('.json')).toBe('application/json') - expect(getMimeType('.py')).toBe('text/x-python') - expect(getMimeType('.pdf')).toBe('application/pdf') - expect(getMimeType('.png')).toBe('image/png') - expect(getMimeType('.svg')).toBe('image/svg+xml') - }) - - it('should return undefined for unknown extensions', () => { - expect(getMimeType('.xyz')).toBeUndefined() - expect(getMimeType('.unknown')).toBeUndefined() - }) -}) - -describe('sKILL_RESOURCE_TEXT_EXTENSIONS', () => { - it('should include common code file extensions', () => { - const codeExtensions = ['.kt', '.java', '.py', '.ts', '.js', '.go', '.rs', '.c', '.cpp'] - for (const ext of codeExtensions) expect(SKILL_RESOURCE_TEXT_EXTENSIONS).toContain(ext) - }) - - it('should include data file extensions', () => { - const dataExtensions = ['.sql', '.json', '.xml', '.yaml', '.csv'] - for (const ext of dataExtensions) expect(SKILL_RESOURCE_TEXT_EXTENSIONS).toContain(ext) - }) -}) - -describe('sKILL_RESOURCE_BINARY_EXTENSIONS', () => { - it('should include document file extensions', () => { - const docExtensions = ['.docx', '.pdf', '.xlsx', '.pptx'] - for (const ext of docExtensions) expect(SKILL_RESOURCE_BINARY_EXTENSIONS).toContain(ext) - }) - - it('should include image file extensions', () => { - const imageExtensions = ['.png', '.jpg', '.gif', '.webp'] - for (const ext of imageExtensions) expect(SKILL_RESOURCE_BINARY_EXTENSIONS).toContain(ext) - }) -}) diff --git a/cli/src/utils/WriteHelper.ts b/cli/src/utils/WriteHelper.ts index 61e53916..8364b2ec 100644 --- a/cli/src/utils/WriteHelper.ts +++ b/cli/src/utils/WriteHelper.ts @@ -38,9 +38,9 @@ export function createSkillDirPath(basePath: string, skillsSubDir: string, skill } /** - * Create a fast command output path + * Create a command output path */ -export function createFastCommandOutputPath( +export function createCommandOutputPath( globalDir: string, commandsSubDir: string, fileName: string diff --git a/cli/src/utils/ruleFilter.property.test.ts b/cli/src/utils/ruleFilter.property.test.ts deleted file mode 100644 index a68be86f..00000000 --- a/cli/src/utils/ruleFilter.property.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type {ProjectConfig, RulePrompt} from '@truenine/plugin-shared' -import {FilePathKind, PromptKind} from '@truenine/plugin-shared' -import * as fc from 'fast-check' -import {describe, expect, it} from 'vitest' -import {applySubSeriesGlobPrefix, filterRulesByProjectConfig} from './ruleFilter' - -function createMockRulePrompt(seriName: string | string[] | undefined, globs: readonly string[] = ['**/*.ts']): RulePrompt { - const content = '# Rule body' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: {pathKind: FilePathKind.Relative, path: '.', basePath: '', getDirectoryName: () => '.', getAbsolutePath: () => '.'}, - markdownContents: [], - yamlFrontMatter: {description: 'Test rule', globs: [...globs]}, - series: 'test', - ruleName: 'test-rule', - globs: [...globs], - scope: 'project', - seriName - } -} - -const seriNameGen = fc.stringMatching(/^[a-z0-9]{1,20}$/) -const seriNameArrayGen = fc.array(seriNameGen, {minLength: 0, maxLength: 10}) -const globGen = fc.stringMatching(/^\*\*\/\*\.[a-z]{1,5}$/) -const globArrayGen = fc.array(globGen, {minLength: 1, maxLength: 5}) - -const subdirGen = fc.stringMatching(/^[a-z][a-z0-9/-]{0,30}$/) -const subSeriesGen = fc.dictionary(subdirGen, seriNameArrayGen) - -describe('filterRulesByProjectConfig property tests', () => { - it('should return all rules when projectConfig is undefined', async () => { - await fc.assert( - fc.asyncProperty(seriNameArrayGen, async seriNames => { - const rules = seriNames.map(name => createMockRulePrompt(name)) - const result = filterRulesByProjectConfig(rules, void 0) - expect(result).toHaveLength(rules.length) - }), - {numRuns: 100} - ) - }) - - it('should filter rules deterministically', async () => { - await fc.assert( - fc.asyncProperty( - seriNameArrayGen, - seriNameArrayGen, - async (ruleNames, includeNames) => { - const rules = ruleNames.map(name => createMockRulePrompt(name)) - const projectConfig: ProjectConfig = { - rules: {includeSeries: includeNames} - } - const result1 = filterRulesByProjectConfig(rules, projectConfig) - const result2 = filterRulesByProjectConfig(rules, projectConfig) - expect(result1).toEqual(result2) - } - ), - {numRuns: 100} - ) - }) - - it('should return subset when includeSeries is specified', async () => { - await fc.assert( - fc.asyncProperty( - seriNameArrayGen, - seriNameArrayGen, - async (ruleNames, includeNames) => { - const rules = ruleNames.map(name => createMockRulePrompt(name)) - const projectConfig: ProjectConfig = { - rules: {includeSeries: includeNames} - } - const result = filterRulesByProjectConfig(rules, projectConfig) - expect(result.length).toBeLessThanOrEqual(rules.length) - } - ), - {numRuns: 100} - ) - }) - - it('should only return rules with matching seriName when includeSeries is non-empty', async () => { - await fc.assert( - fc.asyncProperty( - seriNameArrayGen, - fc.array(seriNameGen, {minLength: 1, maxLength: 10}), - async (ruleNames, includeNames) => { - const rules = ruleNames.map(name => createMockRulePrompt(name)) - const projectConfig: ProjectConfig = { - rules: {includeSeries: includeNames} - } - const result = filterRulesByProjectConfig(rules, projectConfig) - for (const rule of result) { - if (rule.seriName != null) { - const matched = typeof rule.seriName === 'string' - ? includeNames.includes(rule.seriName) - : rule.seriName.some(n => includeNames.includes(n)) - expect(matched).toBe(true) - } - } - } - ), - {numRuns: 100} - ) - }) - - it('should always include rules with undefined seriName', async () => { - await fc.assert( - fc.asyncProperty( - seriNameArrayGen, - seriNameArrayGen, - async (definedNames, includeNames) => { - const rules = [ - ...definedNames.map(name => createMockRulePrompt(name)), - createMockRulePrompt(void 0) - ] - const projectConfig: ProjectConfig = { - rules: {includeSeries: includeNames} - } - const result = filterRulesByProjectConfig(rules, projectConfig) - const hasUndefinedSeriName = result.some(r => r.seriName === void 0) - expect(hasUndefinedSeriName).toBe(true) - } - ), - {numRuns: 100} - ) - }) -}) - -describe('applySubSeriesGlobPrefix property tests', () => { - it('should return original rules when no projectConfig', async () => { - await fc.assert( - fc.asyncProperty( - seriNameGen, - globArrayGen, - async (seriName, globs) => { - const rules = [createMockRulePrompt(seriName, globs)] - const result = applySubSeriesGlobPrefix(rules, void 0) - expect(result).toEqual(rules) - } - ), - {numRuns: 100} - ) - }) - - it('should return original rules when no subSeries', async () => { - await fc.assert( - fc.asyncProperty( - seriNameGen, - globArrayGen, - async (seriName, globs) => { - const rules = [createMockRulePrompt(seriName, globs)] - const projectConfig: ProjectConfig = {rules: {includeSeries: [seriName]}} - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result).toEqual(rules) - } - ), - {numRuns: 100} - ) - }) - - it('should not modify rules with undefined seriName', async () => { - await fc.assert( - fc.asyncProperty( - globArrayGen, - subSeriesGen, - async (globs, subSeries) => { - const rules = [createMockRulePrompt(void 0, globs)] - const projectConfig: ProjectConfig = {rules: {subSeries}} - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0].globs).toEqual(globs) - } - ), - {numRuns: 100} - ) - }) - - it('should always produce valid glob patterns', async () => { - await fc.assert( - fc.asyncProperty( - seriNameGen, - globArrayGen, - subdirGen, - async (seriName, globs, subdir) => { - const rules = [createMockRulePrompt(seriName, globs)] - const projectConfig: ProjectConfig = { - rules: {subSeries: {[subdir]: [seriName]}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - for (const glob of result[0].globs) { - expect(typeof glob).toBe('string') - expect(glob.length).toBeGreaterThan(0) - } - } - ), - {numRuns: 100} - ) - }) - - it('should produce at least one glob per unique subdir when matched', async () => { - await fc.assert( - fc.asyncProperty( - seriNameGen, - globArrayGen, - fc.array(subdirGen, {minLength: 1, maxLength: 5}), - async (seriName, globs, subdirs) => { - const rules = [createMockRulePrompt(seriName, globs)] - const subSeries: Record = {} - for (const subdir of subdirs) subSeries[subdir] = [seriName] - const projectConfig: ProjectConfig = {rules: {subSeries}} - const result = applySubSeriesGlobPrefix(rules, projectConfig) - const uniqueSubdirs = new Set(subdirs).size - expect(result[0].globs.length).toBeGreaterThanOrEqual(uniqueSubdirs) - } - ), - {numRuns: 100} - ) - }) - - it('should be deterministic', async () => { - await fc.assert( - fc.asyncProperty( - seriNameGen, - globArrayGen, - subSeriesGen, - async (seriName, globs, subSeries) => { - const rules = [createMockRulePrompt(seriName, globs)] - const projectConfig: ProjectConfig = {rules: {subSeries}} - const result1 = applySubSeriesGlobPrefix(rules, projectConfig) - const result2 = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result1).toEqual(result2) - } - ), - {numRuns: 100} - ) - }) - - it('should preserve rule count', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(seriNameGen, {minLength: 0, maxLength: 10}), - globArrayGen, - subSeriesGen, - async (seriNames, globs, subSeries) => { - const rules = seriNames.map(name => createMockRulePrompt(name, globs)) - const projectConfig: ProjectConfig = {rules: {subSeries}} - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result).toHaveLength(rules.length) - } - ), - {numRuns: 100} - ) - }) -}) diff --git a/cli/src/utils/ruleFilter.test.ts b/cli/src/utils/ruleFilter.test.ts deleted file mode 100644 index fa761ae2..00000000 --- a/cli/src/utils/ruleFilter.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type {ProjectConfig, RulePrompt} from '@truenine/plugin-shared' -import {FilePathKind, NamingCaseKind, PromptKind} from '@truenine/plugin-shared' -import {describe, expect, it} from 'vitest' -import {applySubSeriesGlobPrefix, filterRulesByProjectConfig} from './ruleFilter' - -function createMockRulePrompt(seriName: string | string[] | null, globs: readonly string[] = ['**/*.ts']): RulePrompt { - const content = '# Rule body' - return { - type: PromptKind.Rule, - content, - length: content.length, - filePathKind: FilePathKind.Relative, - dir: {pathKind: FilePathKind.Relative, path: '.', basePath: '', getDirectoryName: () => '.', getAbsolutePath: () => '.'}, - markdownContents: [], - yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase, description: 'Test rule', globs: [...globs]}, - series: 'test', - ruleName: 'test-rule', - globs: [...globs], - scope: 'project', - seriName - } -} - -describe('filterRulesByProjectConfig', () => { - it('should return all rules when no projectConfig', () => { - const rules = [ - createMockRulePrompt('uniapp'), - createMockRulePrompt('vue'), - createMockRulePrompt(null) - ] - const result = filterRulesByProjectConfig(rules, void 0) - expect(result).toHaveLength(3) - }) - - it('should return all rules when no rules config', () => { - const rules = [createMockRulePrompt('uniapp'), createMockRulePrompt('vue')] - const projectConfig: ProjectConfig = {mcp: {names: ['test']}} - const result = filterRulesByProjectConfig(rules, projectConfig) - expect(result).toHaveLength(2) - }) - - describe('includeSeries filtering', () => { - it('should include only matching seriName', () => { - const rules = [createMockRulePrompt('uniapp'), createMockRulePrompt('vue')] - const projectConfig: ProjectConfig = {rules: {includeSeries: ['uniapp']}} - const result = filterRulesByProjectConfig(rules, projectConfig) - expect(result).toHaveLength(1) - expect(result[0]!.seriName).toBe('uniapp') - }) - - it('should always include rules with null seriName', () => { - const rules = [createMockRulePrompt(null), createMockRulePrompt('vue')] - const projectConfig: ProjectConfig = {rules: {includeSeries: ['uniapp']}} - const result = filterRulesByProjectConfig(rules, projectConfig) - expect(result).toHaveLength(1) - expect(result[0]!.seriName).toBeNull() - }) - - it('should return all rules when includeSeries is empty', () => { - const rules = [createMockRulePrompt('uniapp'), createMockRulePrompt('vue')] - const projectConfig: ProjectConfig = {rules: {includeSeries: []}} - const result = filterRulesByProjectConfig(rules, projectConfig) - expect(result).toHaveLength(2) - }) - - it('should handle multiple includeSeries values', () => { - const rules = [ - createMockRulePrompt('uniapp'), - createMockRulePrompt('vue'), - createMockRulePrompt('react') - ] - const projectConfig: ProjectConfig = {rules: {includeSeries: ['uniapp', 'vue']}} - const result = filterRulesByProjectConfig(rules, projectConfig) - expect(result).toHaveLength(2) - expect(result.map(r => r.seriName)).toContain('uniapp') - expect(result.map(r => r.seriName)).toContain('vue') - }) - - it('should support top-level includeSeries', () => { - const rules = [createMockRulePrompt('uniapp'), createMockRulePrompt('vue')] - const projectConfig: ProjectConfig = {includeSeries: ['uniapp']} - const result = filterRulesByProjectConfig(rules, projectConfig) - expect(result).toHaveLength(1) - expect(result[0]!.seriName).toBe('uniapp') - }) - - it('should merge top-level and rules-level includeSeries', () => { - const rules = [ - createMockRulePrompt('uniapp'), - createMockRulePrompt('vue'), - createMockRulePrompt('react') - ] - const projectConfig: ProjectConfig = { - includeSeries: ['uniapp'], - rules: {includeSeries: ['vue']} - } - const result = filterRulesByProjectConfig(rules, projectConfig) - expect(result).toHaveLength(2) - expect(result.map(r => r.seriName)).toContain('uniapp') - expect(result.map(r => r.seriName)).toContain('vue') - }) - - it('should handle seriName as string array', () => { - const rules = [createMockRulePrompt(['uniapp', 'vue']), createMockRulePrompt('react')] - const projectConfig: ProjectConfig = {rules: {includeSeries: ['uniapp']}} - const result = filterRulesByProjectConfig(rules, projectConfig) - expect(result).toHaveLength(1) - expect(result[0]!.seriName).toEqual(['uniapp', 'vue']) - }) - }) - - describe('edge cases', () => { - it('should handle empty rules array', () => { - const projectConfig: ProjectConfig = {rules: {includeSeries: ['uniapp']}} - const result = filterRulesByProjectConfig([], projectConfig) - expect(result).toHaveLength(0) - }) - - it('should return no rules when includeSeries has no matches', () => { - const rules = [createMockRulePrompt('uniapp'), createMockRulePrompt('vue')] - const projectConfig: ProjectConfig = {rules: {includeSeries: ['react']}} - const result = filterRulesByProjectConfig(rules, projectConfig) - expect(result).toHaveLength(0) - }) - }) -}) - -describe('applySubSeriesGlobPrefix', () => { - describe('basic functionality', () => { - it('should return original rules when no projectConfig', () => { - const rules = [createMockRulePrompt('uniapp')] - const result = applySubSeriesGlobPrefix(rules, void 0) - expect(result).toEqual(rules) - }) - - it('should return original rules when no subSeries config', () => { - const rules = [createMockRulePrompt('uniapp')] - const projectConfig: ProjectConfig = {rules: {includeSeries: ['uniapp']}} - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result).toEqual(rules) - }) - - it('should return original rules when empty subSeries', () => { - const rules = [createMockRulePrompt('uniapp')] - const projectConfig: ProjectConfig = {rules: {subSeries: {}}} - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result).toEqual(rules) - }) - - it('should return original rules when seriName is null', () => { - const rules = [createMockRulePrompt(null)] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result).toEqual(rules) - }) - }) - - describe('glob prefix addition', () => { - it('should add prefix to globs when seriName matches', () => { - const rules = [createMockRulePrompt('uniapp3', ['**/*.vue'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/**/*.vue']) - }) - - it('should add prefix to multiple globs', () => { - const rules = [createMockRulePrompt('uniapp3', ['**/*.vue', '**/*.ts'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/**/*.vue', 'applet/**/*.ts']) - }) - - it('should add multiple prefixes when seriName matches multiple subdirs', () => { - const rules = [createMockRulePrompt('uniapp3', ['**/*.vue'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3'], example_applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/**/*.vue', 'example_applet/**/*.vue']) - }) - - it('should return original rule when seriName does not match', () => { - const rules = [createMockRulePrompt('vue', ['**/*.vue'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['**/*.vue']) - }) - }) - - describe('glob format handling', () => { - it('should handle globs starting with **/', () => { - const rules = [createMockRulePrompt('uniapp3', ['**/*.vue'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/**/*.vue']) - }) - - it('should convert globs starting with * to **/ format', () => { - const rules = [createMockRulePrompt('uniapp3', ['*.vue'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/**/*.vue']) - }) - - it('should handle globs with path prefix', () => { - const rules = [createMockRulePrompt('uniapp3', ['src/**/*.ts'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/src/**/*.ts']) - }) - }) - - describe('duplicate prefix prevention', () => { - it('should skip adding prefix when glob already has it', () => { - const rules = [createMockRulePrompt('uniapp3', ['applet/**/*.vue'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/**/*.vue']) - }) - - it('should add only missing prefix when multiple subdirs configured', () => { - const rules = [createMockRulePrompt('uniapp3', ['applet/**/*.vue'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3'], example_applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/**/*.vue', 'example_applet/**/*.vue']) - }) - }) - - describe('subdir path normalization', () => { - it('should normalize subdir path with trailing slash', () => { - const rules = [createMockRulePrompt('uniapp3', ['**/*.vue'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {'applet/': ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/**/*.vue']) - }) - - it('should normalize subdir path with ./ prefix', () => { - const rules = [createMockRulePrompt('uniapp3', ['**/*.vue'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {'./applet': ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/**/*.vue']) - }) - - it('should handle nested subdir paths', () => { - const rules = [createMockRulePrompt('vue', ['**/*.vue'])] - const projectConfig: ProjectConfig = { - rules: {subSeries: {'frontend/apps': ['vue']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['frontend/apps/**/*.vue']) - }) - }) - - describe('edge cases', () => { - it('should handle empty rules array', () => { - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3']}} - } - const result = applySubSeriesGlobPrefix([], projectConfig) - expect(result).toHaveLength(0) - }) - - it('should handle multiple rules with different seriNames', () => { - const rules = [ - createMockRulePrompt('uniapp3', ['**/*.vue']), - createMockRulePrompt('vue', ['**/*.ts']), - createMockRulePrompt(null, ['**/*.js']) - ] - const projectConfig: ProjectConfig = { - rules: {subSeries: {applet: ['uniapp3'], example_applet: ['uniapp3', 'vue']}} - } - const result = applySubSeriesGlobPrefix(rules, projectConfig) - expect(result[0]!.globs).toEqual(['applet/**/*.vue', 'example_applet/**/*.vue']) - expect(result[1]!.globs).toEqual(['example_applet/**/*.ts']) - expect(result[2]!.globs).toEqual(['**/*.js']) - }) - }) -}) diff --git a/cli/src/versionCheck.test.ts b/cli/src/versionCheck.test.ts deleted file mode 100644 index a9d7610c..00000000 --- a/cli/src/versionCheck.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {describe, expect, it, vi} from 'vitest' -import {compareVersions, parseVersion, shouldCheckVersion} from './versionCheck' - -describe('versionCheck', () => { - describe('parseVersion', () => { - it('should parse valid semver versions', () => { - expect(parseVersion('1.0.0')).toEqual([1, 0, 0]) - expect(parseVersion('1.2.3')).toEqual([1, 2, 3]) - expect(parseVersion('10.20.30')).toEqual([10, 20, 30]) - }) - - it('should handle leading v prefix', () => { - expect(parseVersion('v1.0.0')).toEqual([1, 0, 0]) - expect(parseVersion('v2.3.4')).toEqual([2, 3, 4]) - }) - - it('should handle versions with prerelease suffix', () => { - expect(parseVersion('1.0.0-beta')).toEqual([1, 0, 0]) - expect(parseVersion('2.0.0-rc.1')).toEqual([2, 0, 0]) - }) - - it('should return null for invalid versions', () => { - expect(parseVersion('invalid')).toBeNull() - expect(parseVersion('1.0')).toBeNull() - expect(parseVersion('dev')).toBeNull() - expect(parseVersion('')).toBeNull() - }) - }) - - describe('compareVersions', () => { - it('should return 0 for equal versions', () => { - expect(compareVersions('1.0.0', '1.0.0')).toBe(0) - expect(compareVersions('2.3.4', '2.3.4')).toBe(0) - }) - - it('should return -1 when first version is older', () => { - expect(compareVersions('1.0.0', '2.0.0')).toBe(-1) - expect(compareVersions('1.0.0', '1.1.0')).toBe(-1) - expect(compareVersions('1.0.0', '1.0.1')).toBe(-1) - expect(compareVersions('1.9.9', '2.0.0')).toBe(-1) - }) - - it('should return 1 when first version is newer', () => { - expect(compareVersions('2.0.0', '1.0.0')).toBe(1) - expect(compareVersions('1.1.0', '1.0.0')).toBe(1) - expect(compareVersions('1.0.1', '1.0.0')).toBe(1) - expect(compareVersions('2.0.0', '1.9.9')).toBe(1) - }) - - it('should return 0 for invalid versions', () => { - expect(compareVersions('invalid', '1.0.0')).toBe(0) - expect(compareVersions('1.0.0', 'invalid')).toBe(0) - expect(compareVersions('dev', 'dev')).toBe(0) - }) - }) - - describe('shouldCheckVersion', () => { - it('should return true for even minutes', () => { - vi.useFakeTimers() - - vi.setSystemTime(new Date('2025-01-01T12:00:00')) - expect(shouldCheckVersion()).toBe(true) - - vi.setSystemTime(new Date('2025-01-01T12:02:00')) - expect(shouldCheckVersion()).toBe(true) - - vi.setSystemTime(new Date('2025-01-01T12:58:00')) - expect(shouldCheckVersion()).toBe(true) - - vi.useRealTimers() - }) - - it('should return false for odd minutes', () => { - vi.useFakeTimers() - - vi.setSystemTime(new Date('2025-01-01T12:01:00')) - expect(shouldCheckVersion()).toBe(false) - - vi.setSystemTime(new Date('2025-01-01T12:03:00')) - expect(shouldCheckVersion()).toBe(false) - - vi.setSystemTime(new Date('2025-01-01T12:59:00')) - expect(shouldCheckVersion()).toBe(false) - - vi.useRealTimers() - }) - }) -}) diff --git a/cli/src/versionCheck.ts b/cli/src/versionCheck.ts index d5db5209..fff363eb 100644 --- a/cli/src/versionCheck.ts +++ b/cli/src/versionCheck.ts @@ -1,4 +1,4 @@ -import type {ILogger} from '@truenine/plugin-shared' +import type {ILogger} from './plugins/plugin-shared' /** * Get package name from build-time injection or fallback diff --git a/cli/tsconfig.json b/cli/tsconfig.json index fc403126..9f0bb669 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -19,8 +19,6 @@ ], "@truenine/desk-paths": ["./src/plugins/desk-paths/index.ts"], "@truenine/desk-paths/*": ["./src/plugins/desk-paths/*"], - "@truenine/plugin-shared": ["./src/plugins/plugin-shared/index.ts"], - "@truenine/plugin-shared/*": ["./src/plugins/plugin-shared/*"], "@truenine/plugin-output-shared": ["./src/plugins/plugin-output-shared/index.ts"], "@truenine/plugin-output-shared/*": ["./src/plugins/plugin-output-shared/*"], "@truenine/plugin-input-shared": ["./src/plugins/plugin-input-shared/index.ts"], @@ -34,24 +32,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"], @@ -61,8 +41,7 @@ "@truenine/plugin-trae-ide": ["./src/plugins/plugin-trae-ide/index.ts"], "@truenine/plugin-vscode": ["./src/plugins/plugin-vscode/index.ts"], "@truenine/plugin-warp-ide": ["./src/plugins/plugin-warp-ide/index.ts"], - "@truenine/plugin-windsurf": ["./src/plugins/plugin-windsurf/index.ts"], - "@truenine/config": ["./src/config/index.ts"] + "@truenine/plugin-windsurf": ["./src/plugins/plugin-windsurf/index.ts"] }, "resolveJsonModule": true, "allowImportingTsExtensions": true, diff --git a/cli/tsdown.config.ts b/cli/tsdown.config.ts index 69b9cd37..0a27ce53 100644 --- a/cli/tsdown.config.ts +++ b/cli/tsdown.config.ts @@ -1,14 +1,12 @@ 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'), - '@truenine/plugin-shared': resolve('src/plugins/plugin-shared/index.ts'), '@truenine/plugin-output-shared': resolve('src/plugins/plugin-output-shared/index.ts'), '@truenine/plugin-input-shared': resolve('src/plugins/plugin-input-shared/index.ts'), '@truenine/plugin-agentskills-compact': resolve('src/plugins/plugin-agentskills-compact/index.ts'), @@ -47,15 +45,13 @@ const pluginAliases: Record = { '@truenine/plugin-trae-ide': resolve('src/plugins/plugin-trae-ide/index.ts'), '@truenine/plugin-vscode': resolve('src/plugins/plugin-vscode/index.ts'), '@truenine/plugin-warp-ide': resolve('src/plugins/plugin-warp-ide/index.ts'), - '@truenine/plugin-windsurf': resolve('src/plugins/plugin-windsurf/index.ts'), - '@truenine/config': resolve('src/config/index.ts') + '@truenine/plugin-windsurf': resolve('src/plugins/plugin-windsurf/index.ts') } const noExternalDeps = [ '@truenine/logger', 'fast-glob', '@truenine/desk-paths', - '@truenine/init-bundle', '@truenine/md-compiler', ...Object.keys(pluginAliases) ] diff --git a/cli/vite.config.ts b/cli/vite.config.ts index 891d70bf..ecfd1c94 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -1,17 +1,13 @@ 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'), - '@truenine/plugin-shared': resolve('src/plugins/plugin-shared/index.ts'), - '@truenine/plugin-shared/types': resolve('src/plugins/plugin-shared/types/index.ts'), - '@truenine/plugin-shared/testing': resolve('src/plugins/plugin-shared/testing/index.ts'), '@truenine/plugin-output-shared': resolve('src/plugins/plugin-output-shared/index.ts'), '@truenine/plugin-output-shared/utils': resolve('src/plugins/plugin-output-shared/utils/index.ts'), '@truenine/plugin-output-shared/registry': resolve('src/plugins/plugin-output-shared/registry/index.ts'), @@ -53,8 +49,7 @@ const pluginAliases: Record = { '@truenine/plugin-trae-ide': resolve('src/plugins/plugin-trae-ide/index.ts'), '@truenine/plugin-vscode': resolve('src/plugins/plugin-vscode/index.ts'), '@truenine/plugin-warp-ide': resolve('src/plugins/plugin-warp-ide/index.ts'), - '@truenine/plugin-windsurf': resolve('src/plugins/plugin-windsurf/index.ts'), - '@truenine/config': resolve('src/config/index.ts') + '@truenine/plugin-windsurf': resolve('src/plugins/plugin-windsurf/index.ts') } export default defineConfig({ diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index 482bee96..57ea93fe 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -9,6 +9,7 @@ export default mergeConfig( defineConfig({ test: { environment: 'node', + passWithNoTests: true, exclude: [...configDefaults.exclude, 'e2e/*'], root: fileURLToPath(new URL('./', import.meta.url)), typecheck: { diff --git a/doc/app/advanced.mdx b/doc/app/advanced.mdx index dfa8505c..5fbd1b7a 100644 --- a/doc/app/advanced.mdx +++ b/doc/app/advanced.mdx @@ -7,8 +7,8 @@ lastUpdated: 2026-02-18

工作区与项目分层

- memory-sync 推荐使用「全局规则仓库 + 各项目本地规则」的结构,通过 workspace 配置和 shadow - project 组合,实现既统一又可扩展的管理方式。 + memory-sync 推荐使用「全局规则仓库 + 各项目本地规则」的结构,通过 workspace 配置和 aindex + 组合,实现既统一又可扩展的管理方式。

与多 AI 客户端协作

diff --git a/doc/app/config.mdx b/doc/app/config.mdx index cd0c43cc..809b1025 100644 --- a/doc/app/config.mdx +++ b/doc/app/config.mdx @@ -30,17 +30,17 @@ lastUpdated: 2026-02-18 } ``` -

shadowSourceProject 配置

+

aindex 配置

- shadowSourceProject 描述了一套「影子项目」目录结构,用于统一管理 Skills、Commands、Agents、Rules 以及 Global Memory 等文件。 + aindex 描述了一套 Aindex 目录结构,用于统一管理 Skills、Commands、Agents、Rules 以及 Global Memory 等文件。

```json { - "shadowSourceProject": { - "name": "tnmsc-shadow", + "aindex": { + "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": { diff --git a/doc/package.json b/doc/package.json index d8dfad86..3439cae1 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.10919", "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 9da7b6bd..45293e32 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.10919", "private": true, "engines": { "node": ">=25.2.1", @@ -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/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 6b792606..27b5e324 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.10919" 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..29e4f312 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.10919", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/gui/src/i18n/en-US.json b/gui/src/i18n/en-US.json index a20a6757..418923e7 100644 --- a/gui/src/i18n/en-US.json +++ b/gui/src/i18n/en-US.json @@ -32,8 +32,8 @@ "config.openDir": "Open Config Dir", "config.field.workspaceDir": "Workspace Dir", "config.field.workspaceDir.desc": "Root workspace directory path", - "config.field.shadowSourceProject.name": "Shadow Project Name", - "config.field.shadowSourceProject.name.desc": "Folder name of the shadow source project (inside workspace dir)", + "config.field.aindex.name": "Aindex Name", + "config.field.aindex.name.desc": "Folder name of the aindex (inside workspace dir)", "config.field.logLevel": "Log Level", "config.field.logLevel.desc": "CLI log output level", "plugins.title": "Plugins", diff --git a/gui/src/i18n/zh-CN.json b/gui/src/i18n/zh-CN.json index 6dc0d48e..7a71802c 100644 --- a/gui/src/i18n/zh-CN.json +++ b/gui/src/i18n/zh-CN.json @@ -32,8 +32,8 @@ "config.openDir": "打开配置目录", "config.field.workspaceDir": "工作区目录", "config.field.workspaceDir.desc": "工作区根目录路径", - "config.field.shadowSourceProject.name": "影子项目名称", - "config.field.shadowSourceProject.name.desc": "影子源项目的文件夹名称(位于工作区目录下)", + "config.field.aindex.name": "Aindex 名称", + "config.field.aindex.name.desc": "Aindex 的文件夹名称(位于工作区目录下)", "config.field.logLevel": "日志级别", "config.field.logLevel.desc": "CLI 日志输出级别", "plugins.title": "插件列表", diff --git a/gui/src/pages/ConfigPage.tsx b/gui/src/pages/ConfigPage.tsx index f067c30a..176a05b7 100644 --- a/gui/src/pages/ConfigPage.tsx +++ b/gui/src/pages/ConfigPage.tsx @@ -91,8 +91,8 @@ const ConfigForm: FC = ({ data, onChange, t }) => { onChange(next) }, [data, onChange]) - const shadowSourceProject = (typeof data['shadowSourceProject'] === 'object' && data['shadowSourceProject'] !== null - ? data['shadowSourceProject'] + const aindex = (typeof data['aindex'] === 'object' && data['aindex'] !== null + ? data['aindex'] : {}) as Record return ( @@ -109,11 +109,11 @@ const ConfigForm: FC = ({ data, onChange, t }) => { ))} updateNestedField('shadowSourceProject', 'name', v)} - placeholder="tnmsc-shadow" + label={t('config.field.aindex.name')} + description={t('config.field.aindex.name.desc')} + value={(aindex['name'] as string) ?? ''} + onChange={(v) => updateNestedField('aindex', 'name', v)} + placeholder="aindex" />
diff --git a/gui/src/utils/configValidation.property.test.ts b/gui/src/utils/configValidation.property.test.ts index c54cb56d..d9b5a80e 100644 --- a/gui/src/utils/configValidation.property.test.ts +++ b/gui/src/utils/configValidation.property.test.ts @@ -79,9 +79,9 @@ const arbInvalidLogLevel: fc.Arbitrary = fc.oneof( ) /** - * Arbitrary for an invalid shadowSourceProject value — anything that is not a valid object. + * Arbitrary for an invalid aindex value — anything that is not a valid object. */ -const arbInvalidShadowSourceProject: fc.Arbitrary = fc.oneof( +const arbInvalidAindex: fc.Arbitrary = fc.oneof( fc.string(), fc.integer(), fc.boolean(), @@ -155,16 +155,16 @@ describe('Property 3: 无效配置产生错误', () => { /** * **Validates: Requirements 3.4** * - * For any invalid shadowSourceProject value (non-object), validateConfig should - * return at least one error for the shadowSourceProject field. + * For any invalid aindex value (non-object), validateConfig should + * return at least one error for the aindex field. */ - it('invalid shadowSourceProject values produce errors', () => { + it('invalid aindex values produce errors', () => { fc.assert( - fc.property(arbInvalidShadowSourceProject, (badValue) => { - const config = { shadowSourceProject: badValue } + fc.property(arbInvalidAindex, (badValue) => { + const config = { aindex: badValue } const errors = validateConfig(config) - const sspErrors = errors.filter((e) => e.field.startsWith('shadowSourceProject') && e.severity === 'error') - expect(sspErrors.length).toBeGreaterThan(0) + const aindexErrors = errors.filter((e) => e.field.startsWith('aindex') && e.severity === 'error') + expect(aindexErrors.length).toBeGreaterThan(0) }), { numRuns: 200 }, ) diff --git a/gui/src/utils/configValidation.test.ts b/gui/src/utils/configValidation.test.ts index 5ce69a46..a56ae15e 100644 --- a/gui/src/utils/configValidation.test.ts +++ b/gui/src/utils/configValidation.test.ts @@ -86,60 +86,56 @@ describe('validateConfig — logLevel', () => { }) }) -// ─── shadowSourceProject ─────────────────────────────────────────────── -describe('validateConfig — shadowSourceProject', () => { - const validSsp = { - 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' }, +// ─── aindex ─────────────────────────────────────────────── +describe('validateConfig — aindex', () => { + const validAindex = { + skills: { src: 'skills', dist: 'dist/skills' }, + commands: { src: 'commands', dist: 'dist/commands' }, + subAgents: { src: 'subagents', dist: 'dist/subagents' }, + rules: { src: 'rules', dist: 'dist/rules' }, + globalPrompt: { src: 'global.cn.mdx', dist: 'dist/global.mdx' }, + workspacePrompt: { src: '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' }, } - it('accepts a fully valid shadowSourceProject', () => { - expect(validateConfig({ shadowSourceProject: validSsp })).toHaveLength(0) + it('accepts a fully valid aindex', () => { + expect(validateConfig({ aindex: validAindex })).toHaveLength(0) }) - it('accepts partial shadowSourceProject with only name', () => { - expect(validateConfig({ shadowSourceProject: { name: 'myproject' } })).toHaveLength(0) + it('accepts partial aindex with only skills', () => { + expect(validateConfig({ aindex: { skills: { src: 'skills', dist: 'dist/skills' } } })).toHaveLength(0) }) it('rejects non-object', () => { - const errors = validateConfig({ shadowSourceProject: 'invalid' }) - expect(errorFields(errors)).toContain('shadowSourceProject') + const errors = validateConfig({ aindex: 'invalid' }) + expect(errorFields(errors)).toContain('aindex') }) it('rejects array', () => { - const errors = validateConfig({ shadowSourceProject: ['a'] }) - expect(errorFields(errors)).toContain('shadowSourceProject') - }) - - it('rejects non-string name', () => { - const errors = validateConfig({ shadowSourceProject: { name: 123 } }) - expect(errorFields(errors)).toContain('shadowSourceProject.name') + const errors = validateConfig({ aindex: ['a'] }) + expect(errorFields(errors)).toContain('aindex') }) it('rejects invalid dir pair (non-object)', () => { - const errors = validateConfig({ shadowSourceProject: { name: 'x', skill: 'bad' } }) - expect(errorFields(errors)).toContain('shadowSourceProject.skill') + const errors = validateConfig({ aindex: { skills: 'bad' } }) + expect(errorFields(errors)).toContain('aindex.skills') }) it('rejects dir pair missing src', () => { - const errors = validateConfig({ shadowSourceProject: { name: 'x', skill: { dist: 'dist/skills' } } }) - expect(errorFields(errors)).toContain('shadowSourceProject.skill.src') + const errors = validateConfig({ aindex: { skills: { dist: 'dist/skills' } } }) + expect(errorFields(errors)).toContain('aindex.skills.src') }) it('rejects dir pair missing dist', () => { - const errors = validateConfig({ shadowSourceProject: { name: 'x', skill: { src: 'src/skills' } } }) - expect(errorFields(errors)).toContain('shadowSourceProject.skill.dist') + const errors = validateConfig({ aindex: { skills: { src: 'skills' } } }) + expect(errorFields(errors)).toContain('aindex.skills.dist') }) it('rejects dir pair with non-string src', () => { - const errors = validateConfig({ shadowSourceProject: { name: 'x', skill: { src: 123, dist: 'dist/skills' } } }) - expect(errorFields(errors)).toContain('shadowSourceProject.skill.src') + const errors = validateConfig({ aindex: { skills: { src: 123, dist: 'dist/skills' } } }) + expect(errorFields(errors)).toContain('aindex.skills.src') }) }) @@ -253,15 +249,16 @@ describe('validateConfig — realistic configs', () => { const config = { version: '2026.10218.0', 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' }, + aindex: { + skills: { src: 'skills', dist: 'dist/skills' }, + commands: { src: 'commands', dist: 'dist/commands' }, + subAgents: { src: 'subagents', dist: 'dist/subagents' }, + rules: { src: 'rules', dist: 'dist/rules' }, + globalPrompt: { src: 'global.cn.mdx', dist: 'dist/global.mdx' }, + workspacePrompt: { src: '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: 'debug', profile: { name: 'test' }, @@ -274,13 +271,13 @@ describe('validateConfig — realistic configs', () => { const config = { workspaceDir: 123, logLevel: 'invalid', - shadowSourceProject: 'not-object', + aindex: 'not-object', } const errors = validateConfig(config) const fields = errorFields(errors) expect(fields).toContain('workspaceDir') expect(fields).toContain('logLevel') - expect(fields).toContain('shadowSourceProject') + expect(fields).toContain('aindex') }) it('mixes errors and warnings', () => { diff --git a/gui/src/utils/configValidation.ts b/gui/src/utils/configValidation.ts index 19bba550..e53df4f9 100644 --- a/gui/src/utils/configValidation.ts +++ b/gui/src/utils/configValidation.ts @@ -25,6 +25,7 @@ export interface ValidationError { const KNOWN_FIELDS: ReadonlySet = new Set([ 'version', 'workspaceDir', + 'aindex', 'shadowSourceProject', 'logLevel', 'fastCommandSeriesOptions', @@ -40,14 +41,16 @@ const VALID_LOG_LEVELS: ReadonlySet = new Set([ 'error', ]) -const SHADOW_SOURCE_PROJECT_PAIR_KEYS = [ - 'skill', - 'fastCommand', - 'subAgent', - 'rule', - 'globalMemory', - 'workspaceMemory', - 'project', +const AINDEX_PAIR_KEYS = [ + 'skills', + 'commands', + 'subAgents', + 'rules', + 'globalPrompt', + 'workspacePrompt', + 'app', + 'ext', + 'arch', ] as const /** @@ -113,19 +116,20 @@ export function validateConfig(raw: unknown): readonly ValidationError[] { errors.push({ field: 'workspaceDir', message: 'workspaceDir must be a string', severity: 'error' }) } - // ── shadowSourceProject ────────────────────────────────────────────── - if ('shadowSourceProject' in obj) { - const ssp = obj['shadowSourceProject'] + // ── aindex ────────────────────────────────────────────── + if ('aindex' in obj) { + const ssp = obj['aindex'] if (typeof ssp !== 'object' || ssp === null || Array.isArray(ssp)) { - errors.push({ field: 'shadowSourceProject', message: 'shadowSourceProject must be an object', severity: 'error' }) + errors.push({ field: 'aindex', message: 'aindex must be an object', severity: 'error' }) } else { const sspObj = ssp as Record - if ('name' in sspObj && typeof sspObj['name'] !== 'string') { - errors.push({ field: 'shadowSourceProject.name', message: 'shadowSourceProject.name must be a string', severity: 'error' }) + // Validate dir field (optional string) + if ('dir' in sspObj && typeof sspObj['dir'] !== 'string') { + errors.push({ field: 'aindex.dir', message: 'aindex.dir must be a string', severity: 'error' }) } - for (const key of SHADOW_SOURCE_PROJECT_PAIR_KEYS) { + for (const key of AINDEX_PAIR_KEYS) { if (key in sspObj) { - errors.push(...validateDirPair(sspObj[key], `shadowSourceProject.${key}`)) + errors.push(...validateDirPair(sspObj[key], `aindex.${key}`)) } } } diff --git a/libraries/config/Cargo.toml b/libraries/config/Cargo.toml deleted file mode 100644 index 67195625..00000000 --- a/libraries/config/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "tnmsc-config" -description = "Configuration loading, merging, and validation for tnmsc" -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] -tnmsc-logger = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -dirs = { workspace = true } -sha2 = { workspace = true } -napi = { workspace = true, optional = true } -napi-derive = { workspace = true, optional = true } - -[build-dependencies] -napi-build = { workspace = true } diff --git a/libraries/config/build.rs b/libraries/config/build.rs deleted file mode 100644 index f2be9938..00000000 --- a/libraries/config/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -fn main() { - #[cfg(feature = "napi")] - napi_build::setup(); -} diff --git a/libraries/config/eslint.config.ts b/libraries/config/eslint.config.ts deleted file mode 100644 index d1de0a15..00000000 --- a/libraries/config/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/config/index.d.ts b/libraries/config/index.d.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/libraries/config/package.json b/libraries/config/package.json deleted file mode 100644 index 069c7917..00000000 --- a/libraries/config/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "@truenine/config", - "type": "module", - "version": "2026.10302.10037", - "private": true, - "description": "Rust-powered configuration loader for Node.js", - "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-config", - "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/config/src/index.ts b/libraries/config/src/index.ts deleted file mode 100644 index bc243856..00000000 --- a/libraries/config/src/index.ts +++ /dev/null @@ -1,148 +0,0 @@ -import {createRequire} from 'node:module' -import process from 'node:process' - -interface NapiConfigModule { - loadUserConfig: (cwd: string) => string - getGlobalConfigPathStr: () => string - mergeConfigs: (baseJson: string, overJson: string) => string - loadConfigFromFile: (filePath: string) => string | null - matchesSeries: (seriName: string | string[] | null | undefined, effectiveIncludeSeries: string[]) => boolean - resolveEffectiveIncludeSeries: (topLevel: string[] | null | undefined, typeSpecific: string[] | null | undefined) => string[] - resolveSubSeries: (topLevel: Record | null | undefined, typeSpecific: Record | null | undefined) => Record -} - -let napiBinding: NapiConfigModule | null = null - -try { - const _require = createRequire(import.meta.url) - const {platform, arch} = process - const platforms: Record = { - 'win32-x64': ['napi-config.win32-x64-msvc', 'win32-x64-msvc'], - 'linux-x64': ['napi-config.linux-x64-gnu', 'linux-x64-gnu'], - 'linux-arm64': ['napi-config.linux-arm64-gnu', 'linux-arm64-gnu'], - 'darwin-arm64': ['napi-config.darwin-arm64', 'darwin-arm64'], - 'darwin-x64': ['napi-config.darwin-x64', 'darwin-x64'] - } - const entry = platforms[`${platform}-${arch}`] - if (entry != null) { - const [local, suffix] = entry - try { - napiBinding = _require(`./${local}.node`) as NapiConfigModule - } - catch { - try { - const pkg = _require(`@truenine/memory-sync-cli-${suffix}`) as Record - napiBinding = pkg['config'] as NapiConfigModule - } - catch {} - } - } -} -catch {} // Native module not available — no pure-TS fallback for config - -if (napiBinding == null) { - console.warn('[tnmsc:config] Native module not available — config operations will return empty/default values. Install the platform-specific package for your OS to enable native config loading.') -} - -/** - * Load and merge user configuration from the given cwd directory. - * Returns the merged config as a parsed object. - */ -export function loadUserConfig(cwd: string): Record { - if (napiBinding == null) return {} - return JSON.parse(napiBinding.loadUserConfig(cwd)) as Record -} - -/** - * Get the global config file path (~/.aindex/.tnmsc.json). - */ -export function getGlobalConfigPath(): string { - if (napiBinding != null) return napiBinding.getGlobalConfigPathStr() - - const home = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '~' - return `${home}/.aindex/.tnmsc.json` -} - -/** - * Merge two config objects. `over` fields take priority over `base`. - */ -export function mergeConfigs( - base: Record, - over: Record -): Record { - if (napiBinding == null) return {...base, ...over} - return JSON.parse(napiBinding.mergeConfigs(JSON.stringify(base), JSON.stringify(over))) as Record -} - -/** - * Load config from a specific file path. Returns null if not found. - */ -export function loadConfigFromFile(filePath: string): Record | null { - if (napiBinding == null) return null - const result = napiBinding.loadConfigFromFile(filePath) - return result != null ? JSON.parse(result) as Record : null -} - -/** - * Compute the effective includeSeries as the set union of top-level and - * type-specific arrays. Returns empty array when both are undefined. - */ -export function resolveEffectiveIncludeSeries( - topLevel?: readonly string[], - typeSpecific?: readonly string[] -): string[] { - if (napiBinding != null) { - return napiBinding.resolveEffectiveIncludeSeries( - topLevel != null ? [...topLevel] : void 0, - typeSpecific != null ? [...typeSpecific] : void 0 - ) - } - if (topLevel == null && typeSpecific == null) return [] // Pure-TS fallback - return [...new Set([...topLevel ?? [], ...typeSpecific ?? []])] -} - -/** - * Determine whether a prompt item should be included based on its seriName - * and the effective includeSeries list. - */ -export function matchesSeries( - seriName: string | readonly string[] | null | undefined, - effectiveIncludeSeries: readonly string[] -): boolean { - if (napiBinding != null) { - return napiBinding.matchesSeries( - seriName != null ? typeof seriName === 'string' ? seriName : [...seriName] : seriName, - [...effectiveIncludeSeries] - ) - } - if (seriName == null) return true // Pure-TS fallback - if (effectiveIncludeSeries.length === 0) return true - if (typeof seriName === 'string') return effectiveIncludeSeries.includes(seriName) - return seriName.some(name => effectiveIncludeSeries.includes(name)) -} - -/** - * Deep-merge two optional subSeries records. For each key present in either - * record, the result is the set union of both value arrays. - */ -export function resolveSubSeries( - topLevel?: Readonly>, - typeSpecific?: Readonly> -): Record { - if (napiBinding != null) { - const toMutable = (r?: Readonly>): Record | undefined => { - if (r == null) return void 0 - const out: Record = {} - for (const [k, v] of Object.entries(r)) out[k] = [...v] - return out - } - return napiBinding.resolveSubSeries(toMutable(topLevel), toMutable(typeSpecific)) - } - if (topLevel == null && typeSpecific == null) return {} // Pure-TS fallback - const merged: Record = {} - for (const [key, values] of Object.entries(topLevel ?? {})) merged[key] = [...values] - for (const [key, values] of Object.entries(typeSpecific ?? {})) { - merged[key] = Object.hasOwn(merged, key) ? [...new Set([...merged[key]!, ...values])] : [...values] - } - return merged -} diff --git a/libraries/config/tsconfig.json b/libraries/config/tsconfig.json deleted file mode 100644 index 0950f1da..00000000 --- a/libraries/config/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/config/tsconfig.lib.json b/libraries/config/tsconfig.lib.json deleted file mode 100644 index 7df70332..00000000 --- a/libraries/config/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/config/tsdown.config.ts b/libraries/config/tsdown.config.ts deleted file mode 100644 index 5cfddf9a..00000000 --- a/libraries/config/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/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 deleted file mode 100644 index e4999868..00000000 --- a/libraries/input-plugins/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "tnmsc-input-plugins" -description = "All 17 input plugins for tnmsc pipeline" -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] -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 } -base64 = { workspace = true } -glob = { workspace = true } -napi = { workspace = true, optional = true } -napi-derive = { workspace = true, optional = true } - -[build-dependencies] -napi-build = { workspace = true } diff --git a/libraries/input-plugins/build.rs b/libraries/input-plugins/build.rs deleted file mode 100644 index f2be9938..00000000 --- a/libraries/input-plugins/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -fn main() { - #[cfg(feature = "napi")] - napi_build::setup(); -} diff --git a/libraries/input-plugins/eslint.config.ts b/libraries/input-plugins/eslint.config.ts deleted file mode 100644 index d1de0a15..00000000 --- a/libraries/input-plugins/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/input-plugins/index.d.ts b/libraries/input-plugins/index.d.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/libraries/input-plugins/package.json b/libraries/input-plugins/package.json deleted file mode 100644 index 80a5320b..00000000 --- a/libraries/input-plugins/package.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "name": "@truenine/input-plugins", - "type": "module", - "version": "2026.10302.10037", - "private": true, - "description": "Rust-powered input plugins for tnmsc pipeline (stub)", - "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-input-plugins", - "npmDir": "npm", - "packageName": "@truenine/input-plugins", - "targets": [ - "x86_64-pc-windows-msvc", - "x86_64-pc-windows-gnu", - "x86_64-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" - }, - "dependencies": { - "@emnapi/runtime": "1.8.1", - "lightningcss": "1.31.1", - "synckit": "0.11.12", - "tsx": "4.21.0", - "yaml": "2.8.2" - }, - "optionalDependencies": { - "@truenine/input-plugins-darwin-arm64": "workspace:*", - "@truenine/input-plugins-darwin-x64": "workspace:*", - "@truenine/input-plugins-linux-x64-gnu": "workspace:*", - "@truenine/input-plugins-win32-x64-gnu": "workspace:*", - "@truenine/input-plugins-win32-x64-msvc": "workspace:*" - }, - "devDependencies": { - "@napi-rs/cli": "^3.5.1", - "npm-run-all2": "catalog:", - "tsdown": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - } -} diff --git a/libraries/input-plugins/src/index.ts b/libraries/input-plugins/src/index.ts deleted file mode 100644 index 42991b2e..00000000 --- a/libraries/input-plugins/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {} // Stub — Rust input-plugins library is currently empty; will be populated when implemented diff --git a/libraries/input-plugins/tsconfig.json b/libraries/input-plugins/tsconfig.json deleted file mode 100644 index 0950f1da..00000000 --- a/libraries/input-plugins/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/input-plugins/tsconfig.lib.json b/libraries/input-plugins/tsconfig.lib.json deleted file mode 100644 index 7df70332..00000000 --- a/libraries/input-plugins/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/input-plugins/tsdown.config.ts b/libraries/input-plugins/tsdown.config.ts deleted file mode 100644 index 5cfddf9a..00000000 --- a/libraries/input-plugins/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/logger/package.json b/libraries/logger/package.json index 9bb361a9..7150b837 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.10919", "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..faf6ff6c 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.10919", "private": true, "description": "Rust-powered MDX→Markdown compiler for Node.js with pure-TS fallback", "license": "AGPL-3.0-only", diff --git a/libraries/plugin-shared/Cargo.toml b/libraries/plugin-shared/Cargo.toml deleted file mode 100644 index afe6d88d..00000000 --- a/libraries/plugin-shared/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "tnmsc-plugin-shared" -description = "Shared types and data structures for tnmsc plugins" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true - -[lib] -crate-type = ["rlib"] - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } diff --git a/package.json b/package.json index 962bc478..9805b0c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync", - "version": "2026.10302.10037", + "version": "2026.10303.10919", "description": "Cross-AI-tool prompt synchronisation toolkit (CLI + Tauri desktop GUI) — one ruleset, multi-target adaptation. Monorepo powered by pnpm + Turbo.", "license": "AGPL-3.0-only", "keywords": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dd5c694..ce636706 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,123 +399,37 @@ 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) - - libraries/config: - 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) - - 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) - - libraries/input-plugins: - dependencies: - '@emnapi/runtime': - specifier: 1.8.1 - version: 1.8.1 - lightningcss: - specifier: 1.31.1 - version: 1.31.1 - synckit: - specifier: 0.11.12 - version: 0.11.12 - tsx: - specifier: 4.21.0 - version: 4.21.0 - yaml: - specifier: 2.8.2 - version: 2.8.2 - 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) - optionalDependencies: - '@truenine/input-plugins-darwin-arm64': - specifier: workspace:* - version: link:npm/darwin-arm64 - '@truenine/input-plugins-darwin-x64': - specifier: workspace:* - version: link:npm/darwin-x64 - '@truenine/input-plugins-linux-x64-gnu': - specifier: workspace:* - version: link:npm/linux-x64-gnu - '@truenine/input-plugins-win32-x64-gnu': - specifier: workspace:* - version: link:npm/win32-x64-gnu - '@truenine/input-plugins-win32-x64-msvc': - specifier: workspace:* - version: link:npm/win32-x64-msvc + 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/logger: 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 +460,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 +475,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 +697,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 +861,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 +938,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 +1095,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 +1104,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 +1113,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 +1122,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 +1131,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 +1153,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 +1162,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 +1171,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 +1180,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 +1189,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 +1198,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 +1207,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 +1740,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 +1761,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 +1845,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 +2005,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 +2078,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 +2103,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 +2116,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 +2341,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 +2375,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 +2390,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 +2403,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 +2429,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 +2442,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 +2453,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 +2463,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 +2476,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 +2487,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 +2643,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 +2652,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 +2677,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 +2701,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 +2916,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 +3006,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 +3063,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 +3151,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 +3479,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 +3501,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 +3513,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 +3616,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 +3823,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 +3967,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 +4116,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 +4135,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 +4271,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 +4329,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 +4362,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 +4459,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 +4643,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 @@ -4937,6 +4884,7 @@ snapshots: '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 + optional: true '@emnapi/wasi-threads@1.1.0': dependencies: @@ -4946,11 +4894,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 +4987,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 +5065,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 +5164,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 +5317,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 +5659,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 +5673,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 +5693,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 +5819,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 +5833,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 +5921,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 +5931,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 +5944,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 +5952,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 +6040,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 +6144,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 +6156,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 +6166,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 +6189,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 +6205,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 +6221,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.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/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 +6321,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 +6330,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 +6338,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 +6353,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 +6380,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 +6392,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 +6418,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 +6426,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 +6442,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 +6464,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 +6524,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 +6599,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 +6616,8 @@ snapshots: bail@2.0.2: {} + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.10.0: {} @@ -6656,7 +6630,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 +6645,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 +6654,7 @@ snapshots: cac@6.7.14: {} - caniuse-lite@1.0.30001772: {} + caniuse-lite@1.0.30001775: {} ccount@2.0.1: {} @@ -6837,7 +6815,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 +6863,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 +6947,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 +6959,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 +6974,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 +6991,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 +7064,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 +7128,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 +7157,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 +7425,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 +7437,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 +7448,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 +7534,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.merge@4.6.2: {} + lodash@4.17.23: {} longest-streak@3.1.0: {} @@ -8024,13 +8015,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 +8050,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 +8159,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 +8328,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 +8339,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 +8421,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 +8515,7 @@ snapshots: tailwind-merge@3.5.0: {} - tailwindcss@4.2.0: {} + tailwindcss@4.2.1: {} tapable@2.3.0: {} @@ -8569,7 +8560,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 +8570,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 +8596,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 +8631,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 +8705,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 +8754,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 +8763,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 +8790,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 +8807,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..e1b3c00d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,9 +4,6 @@ packages: - libraries/* - libraries/logger/npm/* - libraries/md-compiler/npm/* - - libraries/config/npm/* - - libraries/init-bundle/npm/* - - libraries/input-plugins/npm/* - gui - doc @@ -15,9 +12,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 +24,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 +32,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 +52,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..164a471c 100644 --- a/scripts/build-native.ts +++ b/scripts/build-native.ts @@ -6,7 +6,11 @@ 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 NATIVE_MODULES = [ + {name: 'logger', dir: 'libraries/logger'}, + {name: 'md-compiler', dir: 'libraries/md-compiler'}, + {name: 'cli', dir: 'cli'}, +] as const const __dirname = import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)) const root = resolve(__dirname, '..') @@ -48,22 +52,22 @@ const envWithCargo = { } let failed = false -for (const lib of LIBRARIES) { - const libDir = join(root, 'libraries', lib) - console.log(`[build-native] Building ${lib}...`) +for (const mod of NATIVE_MODULES) { + const moduleDir = join(root, mod.dir) + console.log(`[build-native] Building ${mod.name}...`) try { execSync( 'npx napi build --platform --release --output-dir dist -- --features napi', - {stdio: 'inherit', cwd: libDir, env: envWithCargo}, + {stdio: 'inherit', cwd: moduleDir, env: envWithCargo}, ) } catch { - console.error(`[build-native] ${lib}: build failed`) + console.error(`[build-native] ${mod.name}: build failed`) failed = true } } if (failed) { - console.warn('[build-native] Some libraries failed to build, skipping copy') + console.warn('[build-native] Some native modules failed to build, skipping copy') console.warn('[build-native] Ensure Rust toolchain + linker are available, then run: pnpm run build:native') process.exit(0) } diff --git a/scripts/copy-napi.ts b/scripts/copy-napi.ts index f7616dec..cb2ce017 100644 --- a/scripts/copy-napi.ts +++ b/scripts/copy-napi.ts @@ -3,7 +3,11 @@ 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 NATIVE_MODULES = [ + {name: 'logger', distDir: 'libraries/logger/dist'}, + {name: 'md-compiler', distDir: 'libraries/md-compiler/dist'}, + {name: 'cli', distDir: 'cli/dist'}, +] as const const PLATFORM_MAP: Record = { 'win32-x64': 'win32-x64-msvc', @@ -26,22 +30,22 @@ mkdirSync(targetDir, {recursive: true}) let copied = 0 -for (const lib of LIBRARIES) { - const libDist = join(root, 'libraries', lib, 'dist') - if (!existsSync(libDist)) { - console.warn(`[copy-napi] ${lib}: dist/ not found, skipping (run napi build first)`) +for (const mod of NATIVE_MODULES) { + const modDist = join(root, mod.distDir) + if (!existsSync(modDist)) { + console.warn(`[copy-napi] ${mod.name}: dist/ not found, skipping (run napi build first)`) continue } - const nodeFiles = readdirSync(libDist).filter(f => f.endsWith('.node')) + const nodeFiles = readdirSync(modDist).filter(f => f.endsWith('.node')) if (nodeFiles.length === 0) { - console.warn(`[copy-napi] ${lib}: no .node files in dist/, skipping (run napi build first)`) + console.warn(`[copy-napi] ${mod.name}: no .node files in dist/, skipping (run napi build first)`) continue } for (const file of nodeFiles) { - const src = join(libDist, file) + const src = join(modDist, file) const dst = join(targetDir, file) cpSync(src, dst) - console.log(`[copy-napi] ${lib}: ${file} → cli/npm/${suffix}/`) + console.log(`[copy-napi] ${mod.name}: ${file} → cli/npm/${suffix}/`) copied++ } } @@ -52,6 +56,5 @@ if (copied > 0) { console.warn('[copy-napi] No .node files found. Build napi first:') 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') + console.warn(' pnpm -C cli exec napi build --platform --release --output-dir dist -- --features napi') }