diff --git a/.gitignore b/.gitignore index c66f4654..8ccc1c04 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,7 @@ coverage/ .nyc_output *.tmp *.temp -nul -.idea/ -.vscode/ + .npmrc README.md !/README.md @@ -25,3 +23,4 @@ cli/npm/**/*.node libraries/**/dist/*.node **/target/ !**/Cargo.lock +output/ diff --git a/Cargo.lock b/Cargo.lock index e7445a37..fddad2da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2147,7 +2147,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10402.110" +version = "2026.10402.116" dependencies = [ "dirs", "proptest", @@ -4436,7 +4436,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tnmsc" -version = "2026.10402.110" +version = "2026.10402.116" dependencies = [ "clap", "dirs", @@ -4458,7 +4458,7 @@ dependencies = [ [[package]] name = "tnmsc-cli-shell" -version = "2026.10402.110" +version = "2026.10402.116" dependencies = [ "clap", "serde_json", @@ -4468,7 +4468,7 @@ dependencies = [ [[package]] name = "tnmsc-logger" -version = "2026.10402.110" +version = "2026.10402.116" dependencies = [ "napi", "napi-build", @@ -4479,7 +4479,7 @@ dependencies = [ [[package]] name = "tnmsc-md-compiler" -version = "2026.10402.110" +version = "2026.10402.116" dependencies = [ "markdown", "napi", @@ -4494,7 +4494,7 @@ dependencies = [ [[package]] name = "tnmsc-script-runtime" -version = "2026.10402.110" +version = "2026.10402.116" dependencies = [ "napi", "napi-build", diff --git a/Cargo.toml b/Cargo.toml index 9069b5fe..76a4d744 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "2026.10402.110" +version = "2026.10402.116" edition = "2024" rust-version = "1.88" license = "AGPL-3.0-only" diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index 0381c4ed..847b0167 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.10402.110", + "version": "2026.10402.116", "os": [ "darwin" ], diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index d37d6c1d..cc8a9953 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.10402.110", + "version": "2026.10402.116", "os": [ "darwin" ], diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index cea698d9..19469889 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.10402.110", + "version": "2026.10402.116", "os": [ "linux" ], diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index ec2798af..71514b3c 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.10402.110", + "version": "2026.10402.116", "os": [ "linux" ], diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index 30435815..85c818fa 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.10402.110", + "version": "2026.10402.116", "os": [ "win32" ], diff --git a/cli/package.json b/cli/package.json index e3721f61..8f4dd14b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10402.110", + "version": "2026.10402.116", "description": "TrueNine Memory Synchronization CLI shell", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/cli/src/plugin.config.ts b/cli/src/plugin.config.ts index 7820f771..afae340a 100644 --- a/cli/src/plugin.config.ts +++ b/cli/src/plugin.config.ts @@ -9,10 +9,10 @@ import { DroidCLIOutputPlugin, EditorConfigOutputPlugin, GeminiCLIOutputPlugin, - GenericSkillsOutputPlugin, GitExcludeOutputPlugin, JetBrainsAIAssistantCodexOutputPlugin, JetBrainsIDECodeStyleConfigOutputPlugin, + KiroCLIOutputPlugin, OpencodeCLIOutputPlugin, QoderIDEPluginOutputPlugin, ReadmeMdConfigFileOutputPlugin, @@ -48,7 +48,7 @@ export async function createDefaultPluginConfig( new JetBrainsAIAssistantCodexOutputPlugin(), new DroidCLIOutputPlugin(), new GeminiCLIOutputPlugin(), - new GenericSkillsOutputPlugin(), + new KiroCLIOutputPlugin(), new OpencodeCLIOutputPlugin(), new QoderIDEPluginOutputPlugin(), new TraeIDEOutputPlugin(), diff --git a/doc/content/cli/plugin-config.mdx b/doc/content/cli/plugin-config.mdx index 68253618..545ea95a 100644 --- a/doc/content/cli/plugin-config.mdx +++ b/doc/content/cli/plugin-config.mdx @@ -23,11 +23,10 @@ The default output plugins currently assembled in `sdk/src/plugin.config.ts` inc - `AgentsOutputPlugin` - `ClaudeCodeCLIOutputPlugin` -- `CodexCLIOutputPlugin` +- `CodexCLIOutputPlugin` (now also emits Codex skill bundles and per-skill `mcp.json` files) - `JetBrainsAIAssistantCodexOutputPlugin` - `DroidCLIOutputPlugin` - `GeminiCLIOutputPlugin` -- `GenericSkillsOutputPlugin` (deprecated, kept only for compatibility with older skill distribution; cleanup must also remove the global `~/.skills/` directory) - `OpencodeCLIOutputPlugin` - `QoderIDEPluginOutputPlugin` - `TraeIDEOutputPlugin` diff --git a/doc/package.json b/doc/package.json index cd487cdf..da0bcf7b 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-docs", - "version": "2026.10402.110", + "version": "2026.10402.116", "private": true, "description": "Chinese-first manifesto-led documentation site for @truenine/memory-sync.", "engines": { diff --git a/gui/package.json b/gui/package.json index 62cf1f13..bfe8efef 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10402.110", + "version": "2026.10402.116", "private": true, "engines": { "node": ">=25.2.1", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 28d37dc2..f687fe05 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memory-sync-gui" -version = "2026.10402.110" +version = "2026.10402.116" 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 89e5b11e..b0f1ba80 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.10402.110", + "version": "2026.10402.116", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/libraries/logger/package.json b/libraries/logger/package.json index fa14ed21..3b1881cc 100644 --- a/libraries/logger/package.json +++ b/libraries/logger/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/logger", "type": "module", - "version": "2026.10402.110", + "version": "2026.10402.116", "private": true, "description": "Rust-powered AI-friendly Markdown logger for Node.js via N-API", "license": "AGPL-3.0-only", diff --git a/libraries/md-compiler/package.json b/libraries/md-compiler/package.json index fd8a5f69..7be4dac3 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.10402.110", + "version": "2026.10402.116", "private": true, "description": "Rust-powered MDX→Markdown compiler for Node.js with pure-TS fallback", "license": "AGPL-3.0-only", diff --git a/libraries/script-runtime/package.json b/libraries/script-runtime/package.json index fc463844..d3746af1 100644 --- a/libraries/script-runtime/package.json +++ b/libraries/script-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/script-runtime", "type": "module", - "version": "2026.10402.110", + "version": "2026.10402.116", "private": true, "description": "Rust-backed TypeScript proxy runtime for tnmsc", "license": "AGPL-3.0-only", diff --git a/mcp/package.json b/mcp/package.json index c63ddc6a..8e327873 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-mcp", "type": "module", - "version": "2026.10402.110", + "version": "2026.10402.116", "description": "MCP stdio server for managing memory-sync prompt sources and translation artifacts", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/package.json b/package.json index 0fdc56f3..f74bc95c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync", - "version": "2026.10402.110", + "version": "2026.10402.116", "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": [ @@ -40,7 +40,7 @@ "install:rust-deps": "tsx scripts/install-rust-deps.ts", "build:native": "tsx scripts/build-native.ts", "build:native:copy": "tsx scripts/copy-napi.ts", - "postinstall": "simple-git-hooks && pnpm run install:rust-deps && pnpm run build:native" + "postinstall": "tsx scripts/postinstall.ts" }, "author": { "email": "truenine304520@gmail.com", diff --git a/scripts/postinstall.ts b/scripts/postinstall.ts new file mode 100644 index 00000000..8fb1fc69 --- /dev/null +++ b/scripts/postinstall.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env tsx +import {execSync} from 'node:child_process' +import process from 'node:process' + +const CI_ENV_VARS = ['CI', 'GITHUB_ACTIONS', 'VERCEL', 'VERCEL_ENV'] as const + +function hasTruthyEnv(name: (typeof CI_ENV_VARS)[number]): boolean { + const value = process.env[name] + return typeof value === 'string' && value.length > 0 && value !== '0' && value !== 'false' +} + +if (CI_ENV_VARS.some(hasTruthyEnv)) { + console.log('[postinstall] CI or Vercel detected, skipping git hooks and native bootstrap') + process.exit(0) +} + +const commands = [ + 'simple-git-hooks', + 'pnpm run install:rust-deps', + 'pnpm run build:native', +] as const + +for (const command of commands) { + execSync(command, { + stdio: 'inherit', + }) +} diff --git a/sdk/package.json b/sdk/package.json index 68034fa6..78f83439 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-sdk", "type": "module", - "version": "2026.10402.110", + "version": "2026.10402.116", "private": true, "description": "TrueNine Memory Synchronization SDK", "author": "TrueNine", diff --git a/sdk/src/core/cleanup.rs b/sdk/src/core/cleanup.rs index 4d476a91..ff8f2fa7 100644 --- a/sdk/src/core/cleanup.rs +++ b/sdk/src/core/cleanup.rs @@ -20,7 +20,7 @@ const DEFAULT_CLEANUP_SCAN_EXCLUDE_GLOBS: [&str; 6] = [ "**/.next/**", ]; -const EMPTY_DIRECTORY_SCAN_EXCLUDED_BASENAMES: [&str; 17] = [ +const EMPTY_DIRECTORY_SCAN_EXCLUDED_BASENAMES: [&str; 15] = [ ".git", "node_modules", "dist", @@ -34,8 +34,6 @@ const EMPTY_DIRECTORY_SCAN_EXCLUDED_BASENAMES: [&str; 17] = [ ".vite-temp", ".pnpm-store", ".yarn", - ".idea", - ".vscode", ".volumes", "volumes", ]; @@ -1125,15 +1123,34 @@ fn get_protected_path_violation( get_protected_path_violation_for_key(&absolute_target_path, &canonical_target_key, guard) } -fn partition_deletion_targets(paths: &[String], guard: &ProtectedDeletionGuard) -> PartitionResult { +fn target_matches_project_root( + target_path: &str, + project_root_keys: &HashSet, +) -> bool { + build_comparison_keys(target_path) + .into_iter() + .any(|key| project_root_keys.contains(&key)) +} + +fn partition_deletion_targets( + paths: &[String], + guard: &ProtectedDeletionGuard, + exact_safe_paths: Option<&HashSet>, +) -> PartitionResult { let mut safe_paths = Vec::new(); let mut violations = Vec::new(); for target_path in paths { - if let Some(violation) = get_protected_path_violation(target_path, guard) { + let resolved_target_path = path_to_string(&resolve_absolute_path(target_path)); + if exact_safe_paths.is_some_and(|allowed| allowed.contains(&resolved_target_path)) { + safe_paths.push(resolved_target_path); + continue; + } + + if let Some(violation) = get_protected_path_violation(&resolved_target_path, guard) { violations.push(violation); } else { - safe_paths.push(path_to_string(&resolve_absolute_path(target_path))); + safe_paths.push(resolved_target_path); } } @@ -1230,6 +1247,7 @@ fn collect_empty_workspace_directories( files_to_delete: &HashSet, dirs_to_delete: &HashSet, empty_dirs_to_delete: &mut BTreeSet, + retained_directory_roots: &HashSet, empty_dir_absolute_exclude: &Option, empty_dir_relative_exclude: &Option, ) -> bool { @@ -1300,9 +1318,14 @@ fn collect_empty_workspace_directories( files_to_delete, dirs_to_delete, empty_dirs_to_delete, + retained_directory_roots, empty_dir_absolute_exclude, empty_dir_relative_exclude, ) { + if retained_directory_roots.contains(&entry_string) { + has_retained_entries = true; + continue; + } empty_dirs_to_delete.insert(entry_string); continue; } @@ -1338,6 +1361,17 @@ fn plan_workspace_empty_directory_cleanup( .iter() .map(|path| path_to_string(&resolve_absolute_path(path))) .collect::>(); + let retained_directory_roots = guard + .compiled_rules + .iter() + .filter(|rule| rule.protection_mode == ProtectionModeDto::Direct) + .filter_map(|rule| { + fs::symlink_metadata(&rule.path) + .ok() + .filter(|metadata| metadata.is_dir()) + .map(|_| path_to_string(&resolve_absolute_path(&rule.path))) + }) + .collect::>(); let mut discovered_empty_dirs = BTreeSet::new(); collect_empty_workspace_directories( @@ -1346,6 +1380,7 @@ fn plan_workspace_empty_directory_cleanup( &files_to_delete, &dirs_to_delete, &mut discovered_empty_dirs, + &retained_directory_roots, empty_dir_absolute_exclude, empty_dir_relative_exclude, ); @@ -1451,6 +1486,7 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { .map(|value| (*value).to_string()), ); let mut output_path_owners = HashMap::>::new(); + let mut exact_safe_file_paths = HashSet::::new(); let mut protected_glob_targets = Vec::::new(); let mut delete_glob_targets = Vec::::new(); @@ -1514,7 +1550,10 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { delete_dirs.insert(path_to_string(&resolve_absolute_path(&target.path))); } CleanupTargetKindDto::File => { - delete_files.insert(path_to_string(&resolve_absolute_path(&target.path))); + let resolved_target_path = + path_to_string(&resolve_absolute_path(&target.path)); + exact_safe_file_paths.insert(resolved_target_path.clone()); + delete_files.insert(resolved_target_path); } CleanupTargetKindDto::Glob => {} } @@ -1644,7 +1683,8 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { "compiledRuleCount": guard.compiled_rules.len(), }) ); - let file_partition = partition_deletion_targets(&file_candidates, &guard); + let file_partition = + partition_deletion_targets(&file_candidates, &guard, Some(&exact_safe_file_paths)); tnmsc_logger::log_info!( logger, "cleanup native file partition complete", @@ -1662,7 +1702,7 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { "compiledRuleCount": guard.compiled_rules.len(), }) ); - let dir_partition = partition_deletion_targets(&dir_candidates, &guard); + let dir_partition = partition_deletion_targets(&dir_candidates, &guard, None); tnmsc_logger::log_info!( logger, "cleanup native directory partition complete", @@ -1693,7 +1733,7 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { "dirViolations": dir_partition.violations.len(), }) ); - let mut empty_dir_absolute_exclude_patterns = snapshot + let empty_dir_absolute_exclude_patterns = snapshot .empty_dir_exclude_globs .iter() .map(|pattern| { @@ -1707,14 +1747,6 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { } }) .collect::>(); - // Skip workspace project trees entirely during workspace-level empty-directory pruning. - // Their internal cleanup is handled by the project-specific passes already. - empty_dir_absolute_exclude_patterns.extend( - snapshot - .project_roots - .iter() - .map(|project_root| path_to_glob_string(&resolve_absolute_path(project_root))), - ); let empty_dir_absolute_exclude_set = build_globset(&empty_dir_absolute_exclude_patterns)?; let empty_dir_relative_exclude_set = build_globset( &snapshot @@ -1731,7 +1763,12 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { "workspaceDir": snapshot.workspace_dir, }) ); - let (empty_dirs_to_delete, empty_dir_violations) = plan_workspace_empty_directory_cleanup( + let project_root_keys = snapshot + .project_roots + .iter() + .flat_map(|project_root| build_comparison_keys(project_root)) + .collect::>(); + let (raw_empty_dirs_to_delete, raw_empty_dir_violations) = plan_workspace_empty_directory_cleanup( &snapshot.workspace_dir, &files_to_delete, &dirs_to_delete, @@ -1739,6 +1776,14 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { &empty_dir_absolute_exclude_set, &empty_dir_relative_exclude_set, ); + let empty_dirs_to_delete = raw_empty_dirs_to_delete + .into_iter() + .filter(|empty_dir| !target_matches_project_root(empty_dir, &project_root_keys)) + .collect::>(); + let empty_dir_violations = raw_empty_dir_violations + .into_iter() + .filter(|violation| !target_matches_project_root(&violation.target_path, &project_root_keys)) + .collect::>(); tnmsc_logger::log_info!( logger, "cleanup native empty directory planning complete", @@ -2294,7 +2339,7 @@ mod tests { } #[test] - fn skips_workspace_project_trees_during_empty_directory_scan() { + fn prunes_empty_directories_inside_project_trees_without_deleting_project_roots() { let temp_dir = tempdir().unwrap(); let workspace_dir = temp_dir.path().join("workspace"); let project_root = workspace_dir.join("packages/app"); @@ -2309,15 +2354,23 @@ mod tests { snapshot.project_roots = vec![path_to_string(&project_root)]; let plan = plan_cleanup(snapshot).unwrap(); - assert!(!plan + assert!(plan .empty_dirs_to_delete .contains(&path_to_string(&project_root.join("empty")))); - assert!(!plan + assert!(plan .empty_dirs_to_delete .contains(&path_to_string(&project_leaf_dir))); assert!(!plan .empty_dirs_to_delete .contains(&path_to_string(&workspace_dir.join("packages")))); + assert!(!plan + .empty_dirs_to_delete + .contains(&path_to_string(&project_root))); + assert!(!plan + .violations + .iter() + .any(|violation| violation.target_path == path_to_string(&project_root))); + assert!(plan.violations.is_empty()); assert!(plan .empty_dirs_to_delete .contains(&path_to_string(&workspace_dir.join("scratch/empty")))); @@ -2362,6 +2415,41 @@ mod tests { .contains(&path_to_string(®ular_leaf_dir))); } + #[test] + fn prunes_empty_ide_directories() { + let temp_dir = tempdir().unwrap(); + let workspace_dir = temp_dir.path().join("workspace"); + let project_root = workspace_dir.join("packages/app"); + let vscode_dir = project_root.join(".vscode"); + let idea_code_styles_dir = project_root.join(".idea/codeStyles"); + + fs::create_dir_all(&vscode_dir).unwrap(); + fs::create_dir_all(&idea_code_styles_dir).unwrap(); + + let mut snapshot = + single_plugin_snapshot(&workspace_dir, vec![], CleanupDeclarationsDto::default()); + snapshot.project_roots = vec![path_to_string(&project_root)]; + + let plan = plan_cleanup(snapshot).unwrap(); + assert!(plan + .empty_dirs_to_delete + .contains(&path_to_string(&vscode_dir))); + assert!(plan + .empty_dirs_to_delete + .contains(&path_to_string(&idea_code_styles_dir))); + assert!(plan + .empty_dirs_to_delete + .contains(&path_to_string(&project_root.join(".idea")))); + assert!(!plan + .empty_dirs_to_delete + .contains(&path_to_string(&project_root))); + assert!(!plan + .violations + .iter() + .any(|violation| violation.target_path == path_to_string(&project_root))); + assert!(plan.violations.is_empty()); + } + #[test] fn batched_glob_planner_handles_multiple_globs_sharing_root() { let temp_dir = tempdir().unwrap(); diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 12529d9d..87ffd314 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -20,6 +20,7 @@ export * from './plugins/plugin-gemini-cli' export * from './plugins/plugin-git-exclude' export * from './plugins/plugin-jetbrains-ai-codex' export * from './plugins/plugin-jetbrains-codestyle' +export * from './plugins/plugin-kiro' export * from './plugins/plugin-openai-codex-cli' export * from './plugins/plugin-opencode-cli' export * from './plugins/plugin-qoder-ide' diff --git a/sdk/src/plugins/AgentsOutputPlugin.ts b/sdk/src/plugins/AgentsOutputPlugin.ts index 1ae9ab13..8d6a946e 100644 --- a/sdk/src/plugins/AgentsOutputPlugin.ts +++ b/sdk/src/plugins/AgentsOutputPlugin.ts @@ -26,24 +26,12 @@ export class AgentsOutputPlugin extends AbstractOutputPlugin { ctx: OutputCleanContext ): Promise { const declarations = await super.declareCleanupPaths(ctx) - const promptSourceProjects - = ctx.collectedOutputContext.workspace.projects.filter( - project => project.isPromptSourceProject === true - ) - const promptSourceExcludeGlobs = promptSourceProjects - .map(project => project.dirFromWorkspacePath) - .filter((dir): dir is NonNullable => dir != null) - .map(dir => this.resolvePath(dir.basePath, dir.path, '**')) return { ...declarations, delete: [ ...declarations.delete ?? [], ...this.buildProjectPromptCleanupTargets(ctx, PROJECT_MEMORY_FILE) - ], - excludeScanGlobs: [ - ...declarations.excludeScanGlobs ?? [], - ...promptSourceExcludeGlobs ] } } diff --git a/sdk/src/plugins/ClaudeCodeCLIOutputPlugin.test.ts b/sdk/src/plugins/ClaudeCodeCLIOutputPlugin.test.ts new file mode 100644 index 00000000..21fe5dd3 --- /dev/null +++ b/sdk/src/plugins/ClaudeCodeCLIOutputPlugin.test.ts @@ -0,0 +1,64 @@ +import type {OutputCleanContext, Project} from './plugin-core' +import * as fs from 'node:fs' +import * as path from 'node:path' +import {describe, expect, it} from 'vitest' +import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' +import {createLogger, FilePathKind} from './plugin-core' + +function createProject(workspaceBase: string, name: string): Project { + return { + name, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: name, + basePath: workspaceBase, + getDirectoryName: () => name, + getAbsolutePath: () => path.join(workspaceBase, name) + } + } as Project +} + +function createWorkspaceRootProject(): Project { + return { + name: '__workspace__', + isWorkspaceRootProject: true + } as Project +} + +function createCleanContext(workspaceBase: string, projects: readonly Project[]): OutputCleanContext { + return { + logger: createLogger('ClaudeCodeCLIOutputPluginTest', 'error'), + fs, + path, + glob: {} as never, + dryRun: true, + runtimeTargets: {jetbrainsCodexDirs: []}, + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceBase, + getDirectoryName: () => path.basename(workspaceBase) + }, + projects: [...projects] + } + } + } as OutputCleanContext +} + +describe('claudeCodeCLIOutputPlugin cleanup', () => { + it('includes project Claude settings cleanup targets', async () => { + const workspaceBase = path.resolve('tmp/claude-code-cleanup') + const plugin = new ClaudeCodeCLIOutputPlugin() + const cleanup = await plugin.declareCleanupPaths(createCleanContext( + workspaceBase, + [createWorkspaceRootProject(), createProject(workspaceBase, 'consumer-app')] + )) + const deletePaths = cleanup.delete?.map(target => target.path.replaceAll('\\', '/')) ?? [] + + expect(deletePaths).toContain(path.join(workspaceBase, '.claude', 'settings.json').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(workspaceBase, '.claude', 'settings.local.json').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(workspaceBase, 'consumer-app', '.claude', 'settings.json').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(workspaceBase, 'consumer-app', '.claude', 'settings.local.json').replaceAll('\\', '/')) + }) +}) diff --git a/sdk/src/plugins/ClaudeCodeCLIOutputPlugin.ts b/sdk/src/plugins/ClaudeCodeCLIOutputPlugin.ts index cb4fa6f9..664cfa14 100644 --- a/sdk/src/plugins/ClaudeCodeCLIOutputPlugin.ts +++ b/sdk/src/plugins/ClaudeCodeCLIOutputPlugin.ts @@ -51,6 +51,7 @@ export class ClaudeCodeCLIOutputPlugin extends AbstractOutputPlugin { cleanup: { delete: { project: { + files: ['.claude/settings.json', '.claude/settings.local.json'], dirs: [ '.claude/rules', '.claude/commands', @@ -99,24 +100,12 @@ export class ClaudeCodeCLIOutputPlugin extends AbstractOutputPlugin { ctx: OutputCleanContext ): Promise { const declarations = await super.declareCleanupPaths(ctx) - const promptSourceProjects - = ctx.collectedOutputContext.workspace.projects.filter( - project => project.isPromptSourceProject === true - ) - const promptSourceExcludeGlobs = promptSourceProjects - .map(project => project.dirFromWorkspacePath) - .filter((dir): dir is NonNullable => dir != null) - .map(dir => this.resolvePath(dir.basePath, dir.path, '**')) return { ...declarations, delete: [ ...declarations.delete ?? [], ...this.buildProjectPromptCleanupTargets(ctx, PROJECT_MEMORY_FILE) - ], - excludeScanGlobs: [ - ...declarations.excludeScanGlobs ?? [], - ...promptSourceExcludeGlobs ] } } diff --git a/sdk/src/plugins/CodexCLIOutputPlugin.test.ts b/sdk/src/plugins/CodexCLIOutputPlugin.test.ts index ff516f92..88559c7d 100644 --- a/sdk/src/plugins/CodexCLIOutputPlugin.test.ts +++ b/sdk/src/plugins/CodexCLIOutputPlugin.test.ts @@ -1,4 +1,4 @@ -import type {CommandPrompt, InputCapabilityContext, OutputCleanContext, OutputWriteContext, SubAgentPrompt} from './plugin-core' +import type {CommandPrompt, InputCapabilityContext, OutputCleanContext, OutputWriteContext, SkillPrompt, SubAgentPrompt} from './plugin-core' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' @@ -47,7 +47,43 @@ function createInputContext(tempWorkspace: string): InputCapabilityContext { } as InputCapabilityContext } -function createCleanContext(): OutputCleanContext { +function createCleanContext(tempWorkspace?: string): OutputCleanContext { + const workspaceDirectory = tempWorkspace == null + ? { + pathKind: FilePathKind.Relative, + path: '.', + basePath: '.', + getDirectoryName: () => '.', + getAbsolutePath: () => path.resolve('.') + } + : { + pathKind: FilePathKind.Absolute, + path: tempWorkspace, + getDirectoryName: () => path.basename(tempWorkspace) + } + + const projects = tempWorkspace == null + ? [] + : [{ + name: 'project-a', + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: 'project-a', + basePath: tempWorkspace, + getDirectoryName: () => 'project-a', + getAbsolutePath: () => path.join(tempWorkspace, 'project-a') + } + }, { + name: 'project-b', + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: 'project-b', + basePath: tempWorkspace, + getDirectoryName: () => 'project-b', + getAbsolutePath: () => path.join(tempWorkspace, 'project-b') + } + }] + return { logger: { trace: () => {}, @@ -66,14 +102,8 @@ function createCleanContext(): OutputCleanContext { }, collectedOutputContext: { workspace: { - directory: { - pathKind: FilePathKind.Relative, - path: '.', - basePath: '.', - getDirectoryName: () => '.', - getAbsolutePath: () => path.resolve('.') - }, - projects: [] + directory: workspaceDirectory, + projects } } } as OutputCleanContext @@ -83,7 +113,8 @@ function createWriteContext( tempWorkspace: string, commands: readonly CommandPrompt[], subAgents: readonly SubAgentPrompt[] = [], - pluginOptions?: OutputWriteContext['pluginOptions'] + pluginOptions?: OutputWriteContext['pluginOptions'], + skills: readonly SkillPrompt[] = [] ): OutputWriteContext { return { logger: createLogger('CodexCLIOutputPluginTest', 'error'), @@ -121,7 +152,8 @@ function createWriteContext( }] }, commands, - subAgents + subAgents, + skills } } as OutputWriteContext } @@ -196,6 +228,41 @@ function createSubAgentPrompt(scope: 'project' | 'global'): SubAgentPrompt { } as SubAgentPrompt } +function createSkillPrompt( + scope: 'project' | 'global', + name: string +): SkillPrompt { + return { + type: PromptKind.Skill, + content: 'skill body', + length: 10, + filePathKind: FilePathKind.Relative, + skillName: name, + dir: { + pathKind: FilePathKind.Relative, + path: `skills/${name}`, + basePath: path.resolve('tmp/dist/skills'), + getDirectoryName: () => name, + getAbsolutePath: () => path.resolve('tmp/dist/skills', name) + }, + yamlFrontMatter: { + description: 'Skill description', + scope + }, + mcpConfig: { + type: PromptKind.SkillMcpConfig, + mcpServers: { + inspector: { + command: 'npx', + args: ['inspector'] + } + }, + rawContent: '{"mcpServers":{"inspector":{"command":"npx","args":["inspector"]}}}' + }, + markdownContents: [] + } as SkillPrompt +} + describe('codexCLIOutputPlugin command output', () => { it('renders codex commands from dist content instead of the zh source prompt', async () => { await withTempCodexDirs('tnmsc-codex-command', async ({workspace, homeDir}) => { @@ -333,6 +400,126 @@ describe('codexCLIOutputPlugin command output', () => { }) }) + it('writes project-scoped skills and mcp into each project .codex/skills directory', async () => { + await withTempCodexDirs('tnmsc-codex-project-skills', async ({workspace, homeDir}) => { + const plugin = new TestCodexCLIOutputPlugin(homeDir) + const writeCtx = createWriteContext( + workspace, + [], + [], + void 0, + [createSkillPrompt('project', 'ship-it')] + ) + + const declarations = await plugin.declareOutputFiles(writeCtx) + const paths = declarations.map(declaration => declaration.path) + + expect(paths).toContain( + path.join(workspace, 'project-a', '.codex', 'skills', 'ship-it', 'SKILL.md') + ) + expect(paths).toContain( + path.join(workspace, 'project-a', '.codex', 'skills', 'ship-it', 'mcp.json') + ) + expect(paths).toContain( + path.join(workspace, 'project-b', '.codex', 'skills', 'ship-it', 'SKILL.md') + ) + expect(paths).toContain( + path.join(workspace, 'project-b', '.codex', 'skills', 'ship-it', 'mcp.json') + ) + expect(paths).not.toContain( + path.join(homeDir, '.codex', 'skills', 'ship-it', 'SKILL.md') + ) + }) + }) + + it('keeps codex skill files global when only mcp is project-scoped', async () => { + await withTempCodexDirs('tnmsc-codex-split-scope-project-mcp', async ({workspace, homeDir}) => { + const plugin = new TestCodexCLIOutputPlugin(homeDir) + const writeCtx = createWriteContext( + workspace, + [], + [], + { + outputScopes: { + plugins: { + CodexCLIOutputPlugin: { + skills: 'global', + mcp: 'project' + } + } + } + }, + [ + createSkillPrompt('project', 'inspect-locally'), + createSkillPrompt('global', 'ship-it') + ] + ) + + const declarations = await plugin.declareOutputFiles(writeCtx) + const paths = declarations.map(declaration => declaration.path) + + expect(paths).toContain( + path.join(homeDir, '.codex', 'skills', 'ship-it', 'SKILL.md') + ) + expect(paths).toContain( + path.join(workspace, 'project-a', '.codex', 'skills', 'inspect-locally', 'mcp.json') + ) + expect(paths).toContain( + path.join(workspace, 'project-b', '.codex', 'skills', 'inspect-locally', 'mcp.json') + ) + expect(paths).not.toContain( + path.join(workspace, 'project-a', '.codex', 'skills', 'ship-it', 'SKILL.md') + ) + expect(paths).not.toContain( + path.join(homeDir, '.codex', 'skills', 'inspect-locally', 'mcp.json') + ) + }) + }) + + it('keeps codex skill files project-scoped when only mcp is global-scoped', async () => { + await withTempCodexDirs('tnmsc-codex-split-scope-global-mcp', async ({workspace, homeDir}) => { + const plugin = new TestCodexCLIOutputPlugin(homeDir) + const writeCtx = createWriteContext( + workspace, + [], + [], + { + outputScopes: { + plugins: { + CodexCLIOutputPlugin: { + skills: 'project', + mcp: 'global' + } + } + } + }, + [ + createSkillPrompt('project', 'ship-it'), + createSkillPrompt('global', 'inspect-globally') + ] + ) + + const declarations = await plugin.declareOutputFiles(writeCtx) + const paths = declarations.map(declaration => declaration.path) + + expect(paths).toContain( + path.join(workspace, 'project-a', '.codex', 'skills', 'ship-it', 'SKILL.md') + ) + expect(paths).toContain( + path.join(workspace, 'project-b', '.codex', 'skills', 'ship-it', 'SKILL.md') + ) + expect(paths).toContain( + path.join(homeDir, '.codex', 'skills', 'inspect-globally', 'mcp.json') + ) + expect(paths).not.toContain( + path.join(homeDir, '.codex', 'skills', 'ship-it', 'SKILL.md') + ) + expect(paths).not.toContain( + path.join(workspace, 'project-a', '.codex', 'skills', 'inspect-globally', 'mcp.json') + ) + }) + }) + it('cleans global codex skills while preserving the built-in .system directory', async () => { await withTempCodexDirs('tnmsc-codex-cleanup-skills', async ({homeDir}) => { const plugin = new TestCodexCLIOutputPlugin(homeDir) @@ -361,4 +548,40 @@ describe('codexCLIOutputPlugin command output', () => { expect(cleanupPlan.violations).toEqual([]) }) }) + + it('keeps legacy .skills cleanup declarations for both project and global scopes', async () => { + await withTempCodexDirs('tnmsc-codex-cleanup-legacy-skills', async ({workspace, homeDir}) => { + const plugin = new TestCodexCLIOutputPlugin(homeDir) + const cleanupDeclarations = await plugin.declareCleanupPaths(createCleanContext(workspace)) + const deletePaths = cleanupDeclarations.delete?.map(target => target.path.replaceAll('\\', '/')) ?? [] + + expect(deletePaths).toContain( + path.join(workspace, 'project-a', '.skills').replaceAll('\\', '/') + ) + expect(deletePaths).toContain( + path.join(workspace, 'project-b', '.skills').replaceAll('\\', '/') + ) + expect(deletePaths).toContain( + path.join(homeDir, '.skills').replaceAll('\\', '/') + ) + }) + }) + + it('keeps legacy .agents/skills cleanup declarations for both project and global scopes', async () => { + await withTempCodexDirs('tnmsc-codex-cleanup-legacy-agentskills', async ({workspace, homeDir}) => { + const plugin = new TestCodexCLIOutputPlugin(homeDir) + const cleanupDeclarations = await plugin.declareCleanupPaths(createCleanContext(workspace)) + const deletePaths = cleanupDeclarations.delete?.map(target => target.path.replaceAll('\\', '/')) ?? [] + + expect(deletePaths).toContain( + path.join(workspace, 'project-a', '.agents', 'skills').replaceAll('\\', '/') + ) + expect(deletePaths).toContain( + path.join(workspace, 'project-b', '.agents', 'skills').replaceAll('\\', '/') + ) + expect(deletePaths).toContain( + path.join(homeDir, '.agents', 'skills').replaceAll('\\', '/') + ) + }) + }) }) diff --git a/sdk/src/plugins/CodexCLIOutputPlugin.ts b/sdk/src/plugins/CodexCLIOutputPlugin.ts index e11d54f9..6e50bebb 100644 --- a/sdk/src/plugins/CodexCLIOutputPlugin.ts +++ b/sdk/src/plugins/CodexCLIOutputPlugin.ts @@ -1,5 +1,19 @@ -import type {AbstractOutputPluginOptions, OutputCleanContext, OutputCleanupDeclarations} from './plugin-core' -import {AbstractOutputPlugin, PLUGIN_NAMES, resolveSubAgentCanonicalName} from './plugin-core' +import type { + AbstractOutputPluginOptions, + OutputCleanContext, + OutputCleanupDeclarations, + OutputFileDeclaration, + OutputWriteContext, + SkillPrompt +} from './plugin-core' +import {Buffer} from 'node:buffer' +import * as path from 'node:path' +import { + AbstractOutputPlugin, + filterByProjectConfig, + PLUGIN_NAMES, + resolveSubAgentCanonicalName +} from './plugin-core' const PROJECT_MEMORY_FILE = 'AGENTS.md' const GLOBAL_CONFIG_DIR = '.codex' @@ -7,9 +21,21 @@ const PROMPTS_SUBDIR = 'prompts' const AGENTS_SUBDIR = 'agents' const SKILLS_SUBDIR = 'skills' const PRESERVED_SYSTEM_SKILL_DIR = '.system' +const SKILL_FILE_NAME = 'SKILL.md' +const MCP_CONFIG_FILE = 'mcp.json' const CODEX_SUBAGENT_FIELD_ORDER = ['name', 'description', 'developer_instructions'] as const const CODEX_EXCLUDED_SUBAGENT_FIELDS = ['scope', 'seriName', 'argumentHint', 'color', 'namingCase', 'model'] as const +type CodexOutputSource + = | {readonly kind: 'skillMain', readonly skill: SkillPrompt} + | {readonly kind: 'skillMcpConfig', readonly rawContent: string} + | {readonly kind: 'skillChildDoc', readonly content: string} + | { + readonly kind: 'skillResource' + readonly content: string + readonly encoding: 'text' | 'base64' + } + function sanitizeCodexFrontMatter( sourceFrontMatter?: Record ): Record { @@ -57,11 +83,11 @@ const CODEX_OUTPUT_OPTIONS = { cleanup: { delete: { project: { - dirs: ['.codex/agents'] + dirs: ['.codex/agents', '.codex/skills', '.agents/skills', '.skills'] }, global: { files: ['.codex/AGENTS.md'], - dirs: ['.codex/prompts'], + dirs: ['.codex/prompts', '.agents/skills', '.skills'], globs: ['.codex/skills/*'] } }, @@ -88,6 +114,14 @@ const CODEX_OUTPUT_OPTIONS = { subagents: { scopes: ['project'], singleScope: true + }, + skills: { + scopes: ['project', 'global'], + singleScope: true + }, + mcp: { + scopes: ['project', 'global'], + singleScope: true } } } satisfies AbstractOutputPluginOptions @@ -121,4 +155,176 @@ export class CodexCLIOutputPlugin extends AbstractOutputPlugin { }) } } + + override async declareOutputFiles( + ctx: OutputWriteContext + ): Promise { + const declarations = await super.declareOutputFiles(ctx) + const {skills} = ctx.collectedOutputContext + + if (skills == null || skills.length === 0) return declarations + + const selectedSkills = this.selectSingleScopeItems( + skills, + ['project', 'global'], + skill => this.resolveSkillSourceScope(skill), + this.getTopicScopeOverride(ctx, 'skills') + ) + const selectedMcpSkills = this.selectSingleScopeItems( + skills, + ['project', 'global'], + skill => this.resolveSkillSourceScope(skill), + this.getTopicScopeOverride(ctx, 'mcp') + ?? this.getTopicScopeOverride(ctx, 'skills') + ) + + const pushSkillDeclarations = ( + baseDir: string, + scope: 'project' | 'global', + filteredSkills: readonly SkillPrompt[] + ): void => { + for (const skill of filteredSkills) { + const skillName = this.getSkillName(skill) + const skillDir = path.join(baseDir, SKILLS_SUBDIR, skillName) + + declarations.push({ + path: path.join(skillDir, SKILL_FILE_NAME), + scope, + source: {kind: 'skillMain', skill} satisfies CodexOutputSource + }) + + if (skill.childDocs != null) { + for (const childDoc of skill.childDocs) { + declarations.push({ + path: path.join( + skillDir, + childDoc.relativePath.replace(/\.mdx$/, '.md') + ), + scope, + source: { + kind: 'skillChildDoc', + content: childDoc.content as string + } satisfies CodexOutputSource + }) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + declarations.push({ + path: path.join(skillDir, resource.relativePath), + scope, + source: { + kind: 'skillResource', + content: resource.content, + encoding: resource.encoding + } satisfies CodexOutputSource + }) + } + } + } + } + + const pushSkillMcpDeclarations = ( + baseDir: string, + scope: 'project' | 'global', + filteredMcpSkills: readonly SkillPrompt[] + ): void => { + for (const skill of filteredMcpSkills) { + if (skill.mcpConfig == null) continue + + const skillDir = path.join(baseDir, SKILLS_SUBDIR, this.getSkillName(skill)) + declarations.push({ + path: path.join(skillDir, MCP_CONFIG_FILE), + scope, + source: { + kind: 'skillMcpConfig', + rawContent: skill.mcpConfig.rawContent + } satisfies CodexOutputSource + }) + } + } + + if ( + selectedSkills.selectedScope === 'project' + || selectedMcpSkills.selectedScope === 'project' + ) { + for (const project of this.getProjectOutputProjects(ctx)) { + const projectBase = this.resolveProjectConfigDir(ctx, project) + if (projectBase == null) continue + + if (selectedSkills.selectedScope === 'project') { + const filteredSkills = filterByProjectConfig( + selectedSkills.items, + project.projectConfig, + 'skills' + ) + pushSkillDeclarations(projectBase, 'project', filteredSkills) + } + + if (selectedMcpSkills.selectedScope === 'project') { + const filteredMcpSkills = filterByProjectConfig( + selectedMcpSkills.items, + project.projectConfig, + 'skills' + ) + pushSkillMcpDeclarations(projectBase, 'project', filteredMcpSkills) + } + } + } + + if (selectedSkills.selectedScope !== 'global' + && selectedMcpSkills.selectedScope !== 'global') return declarations + + const globalDir = this.getGlobalConfigDir() + const promptSourceProjectConfig = this.resolvePromptSourceProjectConfig(ctx) + if (selectedSkills.selectedScope === 'global') { + const filteredSkills = filterByProjectConfig( + selectedSkills.items, + promptSourceProjectConfig, + 'skills' + ) + pushSkillDeclarations(globalDir, 'global', filteredSkills) + } + if (selectedMcpSkills.selectedScope !== 'global') return declarations + + const filteredMcpSkills = filterByProjectConfig( + selectedMcpSkills.items, + promptSourceProjectConfig, + 'skills' + ) + pushSkillMcpDeclarations(globalDir, 'global', filteredMcpSkills) + return declarations + } + + override async convertContent( + declaration: OutputFileDeclaration, + ctx: OutputWriteContext + ): Promise { + const source = declaration.source as CodexOutputSource | {kind?: string} + + switch (source.kind) { + case 'skillMain': { + const {skill} = source as Extract + const frontMatterData = this.buildSkillFrontMatter(skill) + return this.buildMarkdownContent( + skill.content as string, + frontMatterData, + ctx + ) + } + case 'skillMcpConfig': + return (source as Extract).rawContent + case 'skillChildDoc': + return (source as Extract).content + case 'skillResource': { + const resource = source as Extract + return resource.encoding === 'base64' + ? Buffer.from(resource.content, 'base64') + : resource.content + } + default: + return super.convertContent(declaration, ctx) + } + } } diff --git a/sdk/src/plugins/CursorOutputPlugin.test.ts b/sdk/src/plugins/CursorOutputPlugin.test.ts index 17fab4a4..f51677a3 100644 --- a/sdk/src/plugins/CursorOutputPlugin.test.ts +++ b/sdk/src/plugins/CursorOutputPlugin.test.ts @@ -165,15 +165,20 @@ describe('cursorOutputPlugin cleanup', () => { const normalizedCommandsDir = path.join(tempHomeDir, '.cursor', 'commands').replaceAll('\\', '/') const normalizedStaleDir = staleDir.replaceAll('\\', '/') const normalizedPreservedDir = preservedDir.replaceAll('\\', '/') - const skillCleanupTarget = result.delete?.find(target => target.kind === 'glob' && target.path.includes('skills')) + const skillCleanupTarget = result.delete?.find( + target => target.kind === 'glob' && target.path.replaceAll('\\', '/').includes(`/.cursor/skills-cursor/`) + ) const cleanupPlan = await collectDeletionTargets([plugin], createCleanContext()) const normalizedDeleteDirs = cleanupPlan.dirsToDelete.map(target => target.replaceAll('\\', '/')) + const normalizedViolationTargets = cleanupPlan.violations.map(violation => violation.targetPath.replaceAll('\\', '/')) expect(result.delete?.map(target => target.path.replaceAll('\\', '/')) ?? []).toContain(normalizedCommandsDir) expect(skillCleanupTarget?.excludeBasenames).toEqual(expect.arrayContaining(['create-rule'])) expect(normalizedDeleteDirs).toContain(normalizedStaleDir) expect(normalizedDeleteDirs).not.toContain(normalizedPreservedDir) expect(protectPaths).toContain(normalizedPreservedDir) + expect(normalizedViolationTargets).not.toContain(path.join(preservedDir, 'SKILL.md').replaceAll('\\', '/')) + expect(cleanupPlan.violations).toEqual([]) } finally { fs.rmSync(tempHomeDir, {recursive: true, force: true}) diff --git a/sdk/src/plugins/GeminiCLIOutputPlugin.ts b/sdk/src/plugins/GeminiCLIOutputPlugin.ts index 9c4b9bb2..49b4aa1b 100644 --- a/sdk/src/plugins/GeminiCLIOutputPlugin.ts +++ b/sdk/src/plugins/GeminiCLIOutputPlugin.ts @@ -33,24 +33,12 @@ export class GeminiCLIOutputPlugin extends AbstractOutputPlugin { ctx: OutputCleanContext ): Promise { const declarations = await super.declareCleanupPaths(ctx) - const promptSourceProjects - = ctx.collectedOutputContext.workspace.projects.filter( - project => project.isPromptSourceProject === true - ) - const promptSourceExcludeGlobs = promptSourceProjects - .map(project => project.dirFromWorkspacePath) - .filter((dir): dir is NonNullable => dir != null) - .map(dir => this.resolvePath(dir.basePath, dir.path, '**')) return { ...declarations, delete: [ ...declarations.delete ?? [], ...this.buildProjectPromptCleanupTargets(ctx, PROJECT_MEMORY_FILE) - ], - excludeScanGlobs: [ - ...declarations.excludeScanGlobs ?? [], - ...promptSourceExcludeGlobs ] } } diff --git a/sdk/src/plugins/KiroCLIOutputPlugin.test.ts b/sdk/src/plugins/KiroCLIOutputPlugin.test.ts new file mode 100644 index 00000000..7684a263 --- /dev/null +++ b/sdk/src/plugins/KiroCLIOutputPlugin.test.ts @@ -0,0 +1,157 @@ +import type {OutputCleanContext, Project} from './plugin-core' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import glob from 'fast-glob' +import {describe, expect, it} from 'vitest' +import {collectDeletionTargets} from '@/commands/CleanupUtils' +import {KiroCLIOutputPlugin} from './KiroCLIOutputPlugin' +import {createLogger, FilePathKind} from './plugin-core' + +class TestKiroCLIOutputPlugin extends KiroCLIOutputPlugin { + constructor(private readonly testHomeDir: string) { + super() + } + + protected override getHomeDir(): string { + return this.testHomeDir + } +} + +function createProject(workspaceBase: string, name: string): Project { + return { + name, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: name, + basePath: workspaceBase, + getDirectoryName: () => name, + getAbsolutePath: () => path.join(workspaceBase, name) + } + } as Project +} + +function createWorkspaceRootProject(): Project { + return { + name: '__workspace__', + isWorkspaceRootProject: true + } as Project +} + +function createCleanContext( + workspaceBase: string, + projects: readonly Project[] +): OutputCleanContext { + return { + logger: createLogger('KiroCLIOutputPlugin', 'error'), + fs, + path, + glob, + dryRun: true, + runtimeTargets: {jetbrainsCodexDirs: []}, + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceBase, + getDirectoryName: () => path.basename(workspaceBase) + }, + projects: [...projects] + } + } + } as OutputCleanContext +} + +describe('kiroCLIOutputPlugin cleanup', () => { + it('plans cleanup for configured Kiro streening, specs, and nested mcp paths', async () => { + const tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-kiro-home-')) + const workspaceBase = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-kiro-workspace-')) + const projectName = 'consumer-app' + const projectDir = path.join(workspaceBase, projectName) + const globalStreeningFile = path.join(tempHomeDir, '.kiro', 'streening', 'global.json') + const projectRootStreeningFile = path.join(projectDir, '.kiro', 'streening', 'project.json') + const nestedProjectStreeningFile = path.join(projectDir, 'packages', 'feature', '.kiro', 'streening', 'nested.json') + const nestedProjectMcpFile = path.join(projectDir, 'packages', 'feature', '.kiro', 'settings', 'mcp.json') + const nestedProjectSpecFile = path.join(projectDir, 'packages', 'feature', '.kiro', 'specs', 'plan.md') + + fs.mkdirSync(path.dirname(globalStreeningFile), {recursive: true}) + fs.mkdirSync(path.dirname(projectRootStreeningFile), {recursive: true}) + fs.mkdirSync(path.dirname(nestedProjectStreeningFile), {recursive: true}) + fs.mkdirSync(path.dirname(nestedProjectMcpFile), {recursive: true}) + fs.mkdirSync(path.dirname(nestedProjectSpecFile), {recursive: true}) + fs.writeFileSync(globalStreeningFile, '{}\n', 'utf8') + fs.writeFileSync(projectRootStreeningFile, '{}\n', 'utf8') + fs.writeFileSync(nestedProjectStreeningFile, '{}\n', 'utf8') + fs.writeFileSync(nestedProjectMcpFile, '{}\n', 'utf8') + fs.writeFileSync(nestedProjectSpecFile, '# spec\n', 'utf8') + + try { + const plugin = new TestKiroCLIOutputPlugin(tempHomeDir) + const cleanup = await plugin.declareCleanupPaths( + createCleanContext(workspaceBase, [createProject(workspaceBase, projectName)]) + ) + const deletePaths = cleanup.delete?.map(target => target.path.replaceAll('\\', '/')) ?? [] + const plan = await collectDeletionTargets( + [plugin], + createCleanContext(workspaceBase, [createProject(workspaceBase, projectName)]) + ) + const normalizedFilesToDelete = plan.filesToDelete.map(target => target.replaceAll('\\', '/')) + const normalizedDirsToDelete = plan.dirsToDelete.map(target => target.replaceAll('\\', '/')) + + expect(deletePaths).toContain(path.join(tempHomeDir, '.kiro', 'streening').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(projectDir, '.kiro', 'streening').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(projectDir, '**', '.kiro', 'streening').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(projectDir, '**', '.kiro', 'specs').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(projectDir, '**', '.kiro', 'settings', 'mcp.json').replaceAll('\\', '/')) + expect(normalizedFilesToDelete).toContain(nestedProjectMcpFile.replaceAll('\\', '/')) + expect(normalizedDirsToDelete).toContain(path.dirname(globalStreeningFile).replaceAll('\\', '/')) + expect(normalizedDirsToDelete).toContain(path.dirname(projectRootStreeningFile).replaceAll('\\', '/')) + expect(normalizedDirsToDelete).toContain(path.dirname(nestedProjectStreeningFile).replaceAll('\\', '/')) + expect(normalizedDirsToDelete).toContain(path.dirname(nestedProjectSpecFile).replaceAll('\\', '/')) + expect(plan.violations).toEqual([]) + } finally { + fs.rmSync(tempHomeDir, {recursive: true, force: true}) + fs.rmSync(workspaceBase, {recursive: true, force: true}) + } + }) + + it('plans cleanup for workspace-root .kiro paths', async () => { + const tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-kiro-home-root-')) + const workspaceBase = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-kiro-workspace-root-')) + const workspaceStreeningFile = path.join(workspaceBase, '.kiro', 'streening', 'root.json') + const workspaceMcpFile = path.join(workspaceBase, '.kiro', 'settings', 'mcp.json') + const workspaceSpecFile = path.join(workspaceBase, '.kiro', 'specs', 'plan.md') + + fs.mkdirSync(path.dirname(workspaceStreeningFile), {recursive: true}) + fs.mkdirSync(path.dirname(workspaceMcpFile), {recursive: true}) + fs.mkdirSync(path.dirname(workspaceSpecFile), {recursive: true}) + fs.writeFileSync(workspaceStreeningFile, '{}\n', 'utf8') + fs.writeFileSync(workspaceMcpFile, '{}\n', 'utf8') + fs.writeFileSync(workspaceSpecFile, '# spec\n', 'utf8') + + try { + const plugin = new TestKiroCLIOutputPlugin(tempHomeDir) + const cleanup = await plugin.declareCleanupPaths( + createCleanContext(workspaceBase, [createWorkspaceRootProject()]) + ) + const deletePaths = cleanup.delete?.map(target => target.path.replaceAll('\\', '/')) ?? [] + const plan = await collectDeletionTargets( + [plugin], + createCleanContext(workspaceBase, [createWorkspaceRootProject()]) + ) + const normalizedFilesToDelete = plan.filesToDelete.map(target => target.replaceAll('\\', '/')) + const normalizedDirsToDelete = plan.dirsToDelete.map(target => target.replaceAll('\\', '/')) + + expect(deletePaths).toContain(path.join(workspaceBase, '.kiro', 'streening').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(workspaceBase, '.kiro', 'specs').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(workspaceBase, '.kiro', 'settings', 'mcp.json').replaceAll('\\', '/')) + expect(normalizedFilesToDelete).toContain(workspaceMcpFile.replaceAll('\\', '/')) + expect(normalizedDirsToDelete).toContain(path.dirname(workspaceStreeningFile).replaceAll('\\', '/')) + expect(normalizedDirsToDelete).toContain(path.dirname(workspaceSpecFile).replaceAll('\\', '/')) + expect(plan.violations).toEqual([]) + } finally { + fs.rmSync(tempHomeDir, {recursive: true, force: true}) + fs.rmSync(workspaceBase, {recursive: true, force: true}) + } + }) +}) diff --git a/sdk/src/plugins/KiroCLIOutputPlugin.ts b/sdk/src/plugins/KiroCLIOutputPlugin.ts new file mode 100644 index 00000000..3f580e7d --- /dev/null +++ b/sdk/src/plugins/KiroCLIOutputPlugin.ts @@ -0,0 +1,34 @@ +import {AbstractOutputPlugin} from './plugin-core' + +export class KiroCLIOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('KiroCLIOutputPlugin', { + treatWorkspaceRootProjectAsProject: true, + capabilities: {}, + cleanup: { + delete: { + project: { + globs: [ + '.kiro/streening', + '.kiro/streening/**/*', + '.kiro/specs', + '.kiro/specs/**/*', + '.kiro/settings/mcp.json', + '**/.kiro/streening', + '**/.kiro/streening/**/*', + '**/.kiro/specs', + '**/.kiro/specs/**/*', + '**/.kiro/settings/mcp.json' + ] + }, + global: { + globs: [ + '.kiro/streening', + '.kiro/streening/**/*' + ] + } + } + } + }) + } +} diff --git a/sdk/src/plugins/PromptMarkdownCleanup.test.ts b/sdk/src/plugins/PromptMarkdownCleanup.test.ts index 032e5797..e62f317c 100644 --- a/sdk/src/plugins/PromptMarkdownCleanup.test.ts +++ b/sdk/src/plugins/PromptMarkdownCleanup.test.ts @@ -10,9 +10,12 @@ import * as path from 'node:path' import glob from 'fast-glob' import {describe, expect, it} from 'vitest' import {collectDeletionTargets} from '../commands/CleanupUtils' +import {mergeConfig} from '../config' import {AgentsOutputPlugin} from './AgentsOutputPlugin' import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' +import {CursorOutputPlugin} from './CursorOutputPlugin' import {GeminiCLIOutputPlugin} from './GeminiCLIOutputPlugin' +import {KiroCLIOutputPlugin} from './KiroCLIOutputPlugin' import {FilePathKind, PromptKind} from './plugin-core' interface CleanupTestCase { @@ -157,7 +160,7 @@ function createCleanContext(workspaceDir: string): OutputCleanContext { } describe.each(TEST_CASES)('$name cleanup', ({fileName, createPlugin}) => { - it('cleans workspace and non-prompt project markdown outputs without touching prompt-source paths', async () => { + it('cleans workspace, prompt-source, and non-prompt project markdown outputs', async () => { const tempDir = fs.mkdtempSync( path.join(os.tmpdir(), `tnmsc-${fileName.toLowerCase()}-cleanup-`) ) @@ -205,17 +208,172 @@ describe.each(TEST_CASES)('$name cleanup', ({fileName, createPlugin}) => { expect(normalizedFilesToDelete).toEqual( expect.arrayContaining([ workspaceFile.replaceAll('\\', '/'), + promptSourceRootFile.replaceAll('\\', '/'), + promptSourceChildFile.replaceAll('\\', '/'), projectRootFile.replaceAll('\\', '/'), projectChildFile.replaceAll('\\', '/'), manualProjectChildFile.replaceAll('\\', '/') ]) ) - expect(normalizedFilesToDelete).not.toContain( - promptSourceRootFile.replaceAll('\\', '/') + } finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + }) +}) + +describe('prompt-source cleanup regression', () => { + it('allows exact aindex source prompt outputs to be cleaned without protected-path violations', async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'tnmsc-prompt-source-protected-cleanup-') + ) + const workspaceDir = path.join(tempDir, 'workspace') + const aindexDir = path.join(workspaceDir, 'aindex') + const aindexAppDir = path.join(aindexDir, 'app') + const agentsFile = path.join(aindexAppDir, 'AGENTS.md') + const claudeFile = path.join(aindexAppDir, 'CLAUDE.md') + const geminiFile = path.join(aindexAppDir, 'GEMINI.md') + + fs.mkdirSync(aindexAppDir, {recursive: true}) + fs.writeFileSync(agentsFile, '# agents', 'utf8') + fs.writeFileSync(claudeFile, '# claude', 'utf8') + fs.writeFileSync(geminiFile, '# gemini', 'utf8') + + try { + const result = await collectDeletionTargets( + [ + new AgentsOutputPlugin(), + new ClaudeCodeCLIOutputPlugin(), + new GeminiCLIOutputPlugin() + ], + { + ...createCleanContext(workspaceDir), + pluginOptions: mergeConfig({workspaceDir}), + collectedOutputContext: { + ...createCleanContext(workspaceDir).collectedOutputContext, + aindexDir, + workspace: { + ...createCleanContext(workspaceDir).collectedOutputContext.workspace, + projects: [ + { + name: 'app', + isPromptSourceProject: true, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: path.join('aindex', 'app'), + basePath: workspaceDir, + getDirectoryName: () => 'app', + getAbsolutePath: () => aindexAppDir + }, + rootMemoryPrompt: createRootPrompt('prompt-source root') + } + ] + } + } + } + ) + const normalizedFilesToDelete = result.filesToDelete.map(target => + target.replaceAll('\\', '/')) + const normalizedViolationTargets = result.violations.map(violation => + violation.targetPath.replaceAll('\\', '/')) + + expect(normalizedFilesToDelete).toEqual( + expect.arrayContaining([ + agentsFile.replaceAll('\\', '/'), + claudeFile.replaceAll('\\', '/'), + geminiFile.replaceAll('\\', '/') + ]) + ) + expect(normalizedViolationTargets).not.toContain( + agentsFile.replaceAll('\\', '/') + ) + expect(normalizedViolationTargets).not.toContain( + claudeFile.replaceAll('\\', '/') + ) + expect(normalizedViolationTargets).not.toContain( + geminiFile.replaceAll('\\', '/') + ) + } finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + }) + + it('keeps aindex prompt-source IDE cleanup targets visible to glob-based cleanup', async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'tnmsc-prompt-source-ide-cleanup-') + ) + const workspaceDir = path.join(tempDir, 'workspace') + const promptSourceCursorCommandsDir = path.join( + workspaceDir, + 'aindex', + '.cursor', + 'commands' + ) + const promptSourceCursorSkillDir = path.join( + workspaceDir, + 'aindex', + '.cursor', + 'skills-cursor', + 'ship-it' + ) + const promptSourceKiroStreeningDir = path.join( + workspaceDir, + 'aindex', + '.kiro', + 'streening' + ) + const promptSourceKiroSpecsDir = path.join( + workspaceDir, + 'aindex', + '.kiro', + 'specs' + ) + + fs.mkdirSync(promptSourceCursorCommandsDir, {recursive: true}) + fs.mkdirSync(promptSourceCursorSkillDir, {recursive: true}) + fs.mkdirSync(promptSourceKiroStreeningDir, {recursive: true}) + fs.mkdirSync(promptSourceKiroSpecsDir, {recursive: true}) + fs.writeFileSync( + path.join(promptSourceCursorCommandsDir, 'build.md'), + '# build', + 'utf8' + ) + fs.writeFileSync( + path.join(promptSourceCursorSkillDir, 'SKILL.md'), + '# ship it', + 'utf8' + ) + fs.writeFileSync( + path.join(promptSourceKiroStreeningDir, 'project.json'), + '{}', + 'utf8' + ) + fs.writeFileSync( + path.join(promptSourceKiroSpecsDir, 'plan.md'), + '# plan', + 'utf8' + ) + + try { + const result = await collectDeletionTargets( + [ + new AgentsOutputPlugin(), + new CursorOutputPlugin(), + new KiroCLIOutputPlugin() + ], + createCleanContext(workspaceDir) ) - expect(normalizedFilesToDelete).not.toContain( - promptSourceChildFile.replaceAll('\\', '/') + const normalizedDirsToDelete = result.dirsToDelete.map(target => + target.replaceAll('\\', '/')) + + expect(normalizedDirsToDelete).toEqual( + expect.arrayContaining([ + promptSourceCursorCommandsDir.replaceAll('\\', '/'), + promptSourceCursorSkillDir.replaceAll('\\', '/'), + promptSourceKiroStreeningDir.replaceAll('\\', '/'), + promptSourceKiroSpecsDir.replaceAll('\\', '/') + ]) ) + expect(result.violations).toEqual([]) } finally { fs.rmSync(tempDir, {recursive: true, force: true}) } diff --git a/sdk/src/plugins/WindsurfOutputPlugin.ts b/sdk/src/plugins/WindsurfOutputPlugin.ts index d36208f1..1e35e46f 100644 --- a/sdk/src/plugins/WindsurfOutputPlugin.ts +++ b/sdk/src/plugins/WindsurfOutputPlugin.ts @@ -220,34 +220,32 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { const ignoreFilesByName = new Map( (aiAgentIgnoreConfigFiles ?? []).map(file => [file.fileName, file.content] as const) ) - if (ignoreFilesByName.size > 0) { - const primaryIgnoreContent = ignoreFilesByName.get(IgnoreFiles.WINDSURF) - ?? ignoreFilesByName.get(LEGACY_WINDSURF_IGNORE_FILE) - const legacyIgnoreContent = ignoreFilesByName.get(LEGACY_WINDSURF_IGNORE_FILE) - ?? ignoreFilesByName.get(IgnoreFiles.WINDSURF) + if (ignoreFilesByName.size <= 0) return declarations - for (const project of concreteProjects) { - const projectDir = project.dirFromWorkspacePath - if (projectDir == null || project.isPromptSourceProject === true) continue + const primaryIgnoreContent = ignoreFilesByName.get(IgnoreFiles.WINDSURF) + ?? ignoreFilesByName.get(LEGACY_WINDSURF_IGNORE_FILE) + const legacyIgnoreContent = ignoreFilesByName.get(LEGACY_WINDSURF_IGNORE_FILE) + ?? ignoreFilesByName.get(IgnoreFiles.WINDSURF) + for (const project of concreteProjects) { + const projectDir = project.dirFromWorkspacePath + if (projectDir == null || project.isPromptSourceProject === true) continue - for (const ignoreFileName of WINDSURF_IGNORE_FILES) { - const content = ignoreFileName === IgnoreFiles.WINDSURF - ? primaryIgnoreContent - : legacyIgnoreContent - if (content == null) continue + for (const ignoreFileName of WINDSURF_IGNORE_FILES) { + const content = ignoreFileName === IgnoreFiles.WINDSURF + ? primaryIgnoreContent + : legacyIgnoreContent + if (content == null) continue - declarations.push({ - path: path.join(projectDir.basePath, projectDir.path, ignoreFileName), - scope: 'project', - source: { - kind: 'ignoreFile', - content - } satisfies WindsurfOutputSource - }) - } + declarations.push({ + path: path.join(projectDir.basePath, projectDir.path, ignoreFileName), + scope: 'project', + source: { + kind: 'ignoreFile', + content + } satisfies WindsurfOutputSource + }) } } - return declarations } diff --git a/sdk/src/plugins/plugin-core/AbstractOutputPlugin.ts b/sdk/src/plugins/plugin-core/AbstractOutputPlugin.ts index 33874f07..37fcf17f 100644 --- a/sdk/src/plugins/plugin-core/AbstractOutputPlugin.ts +++ b/sdk/src/plugins/plugin-core/AbstractOutputPlugin.ts @@ -611,7 +611,7 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out }) } - for (const project of this.getProjectPromptOutputProjects(ctx)) { + for (const project of this.getProjectOutputProjects(ctx)) { const projectRootDir = this.resolveProjectRootDir(ctx, project) if (projectRootDir == null) continue @@ -1152,10 +1152,28 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out } async declareCleanupPaths(ctx: OutputCleanContext): Promise { - const cleanupDelete = this.buildCleanupTargetsFromScopeConfig(this.cleanupConfig.delete, 'delete', ctx) - const cleanupProtect = this.buildCleanupTargetsFromScopeConfig(this.cleanupConfig.protect, 'protect', ctx) + const cleanupDelete = [...this.buildCleanupTargetsFromScopeConfig(this.cleanupConfig.delete, 'delete', ctx)] + const cleanupProtect = [...this.buildCleanupTargetsFromScopeConfig(this.cleanupConfig.protect, 'protect', ctx)] const {excludeScanGlobs} = this.cleanupConfig + // Add indexignore files to cleanup targets if this plugin has an indexignore configured + const ignoreOutputPath = this.getIgnoreOutputPath() + if (ignoreOutputPath != null) { + for (const project of this.getProjectOutputProjects(ctx)) { + // Skip workspace root and prompt source projects + if (project.isWorkspaceRootProject === true || project.isPromptSourceProject === true) continue + const projectRootDir = this.resolveProjectRootDir(ctx, project) + if (projectRootDir == null) continue + + cleanupDelete.push({ + path: path.join(projectRootDir, ignoreOutputPath), + kind: 'file', + scope: 'project', + label: 'delete.indexignore' + }) + } + } + if (cleanupDelete.length === 0 && cleanupProtect.length === 0 && (excludeScanGlobs == null || excludeScanGlobs.length === 0)) { return {} } diff --git a/sdk/src/plugins/plugin-kiro.ts b/sdk/src/plugins/plugin-kiro.ts new file mode 100644 index 00000000..045541ea --- /dev/null +++ b/sdk/src/plugins/plugin-kiro.ts @@ -0,0 +1,3 @@ +export { + KiroCLIOutputPlugin +} from './KiroCLIOutputPlugin' diff --git a/sdk/src/runtime/cleanup.ts b/sdk/src/runtime/cleanup.ts index 68ba5774..0ec5d199 100644 --- a/sdk/src/runtime/cleanup.ts +++ b/sdk/src/runtime/cleanup.ts @@ -8,6 +8,7 @@ import type { PluginOptions } from '../plugins/plugin-core' import type {ProtectionMode, ProtectionRuleMatcher} from '../ProtectedDeletionGuard' +import * as path from 'node:path' import {buildDiagnostic, buildFileOperationDiagnostic, diagnosticLines} from '@/diagnostics' import {loadAindexProjectConfig} from '../aindex-config/AindexProjectConfigLoader' import {getNativeBinding} from '../core/native-binding' @@ -268,6 +269,52 @@ function logCleanupPlanDiagnostics( }) } +function collectExactSafeFilePaths(snapshot: NativeCleanupSnapshot): Set { + const exactSafeFilePaths = new Set() + + for (const pluginSnapshot of snapshot.pluginSnapshots) { + for (const outputPath of pluginSnapshot.outputs) { + exactSafeFilePaths.add(path.resolve(outputPath)) + } + + for (const target of pluginSnapshot.cleanup.delete ?? []) { + if (target.kind !== 'file') continue + exactSafeFilePaths.add(path.resolve(target.path)) + } + } + + return exactSafeFilePaths +} + +function reconcileExactSafeFileViolations( + result: T, + exactSafeFilePaths: ReadonlySet +): T { + if (exactSafeFilePaths.size === 0 || result.violations.length === 0) return result + + const rescuedFiles = new Set(result.filesToDelete.map(filePath => path.resolve(filePath))) + const remainingViolations: NativeProtectedPathViolation[] = [] + + for (const violation of result.violations) { + const resolvedTargetPath = path.resolve(violation.targetPath) + if (exactSafeFilePaths.has(resolvedTargetPath)) { + rescuedFiles.add(resolvedTargetPath) + continue + } + + remainingViolations.push(violation) + } + + return { + ...result, + filesToDelete: [...rescuedFiles].sort((a, b) => a.localeCompare(b)), + violations: remainingViolations + } +} + function summarizeCleanupSnapshot(snapshot: NativeCleanupSnapshot): { pluginCount: number outputCount: number @@ -321,8 +368,20 @@ async function buildCleanupSnapshot( ): Promise { const pluginSnapshots = await Promise.all(outputPlugins.map(async plugin => collectPluginCleanupSnapshot(plugin, cleanCtx, predeclaredOutputs))) + // Collect all delete targets from plugin snapshots - these should bypass protection rules + const deleteTargetPaths = new Set() + for (const snapshot of pluginSnapshots) { + if (snapshot.cleanup.delete != null) { + for (const target of snapshot.cleanup.delete) { + deleteTargetPaths.add(path.resolve(target.path)) + } + } + } + const protectedRules: NativeProtectedRule[] = [] for (const rule of collectProtectedInputSourceRules(cleanCtx.collectedOutputContext)) { + // Skip protection rules for paths that are explicitly marked as delete targets + if (deleteTargetPaths.has(path.resolve(rule.path))) continue protectedRules.push({ path: rule.path, protectionMode: mapProtectionMode(rule.protectionMode), @@ -336,6 +395,8 @@ async function buildCleanupSnapshot( for (const rule of collectConfiguredAindexInputRules(cleanCtx.pluginOptions as Required, cleanCtx.collectedOutputContext.aindexDir, { workspaceDir: cleanCtx.collectedOutputContext.workspace.directory.path })) { + // Skip protection rules for paths that are explicitly marked as delete targets + if (deleteTargetPaths.has(path.resolve(rule.path))) continue protectedRules.push({ path: rule.path, protectionMode: mapProtectionMode(rule.protectionMode), @@ -410,7 +471,10 @@ export async function collectDeletionTargets( phase: 'cleanup-plan', ...summarizeCleanupSnapshot(snapshot) }) - const plan = await planCleanupWithNative(snapshot) + const plan = reconcileExactSafeFileViolations( + await planCleanupWithNative(snapshot), + collectExactSafeFilePaths(snapshot) + ) cleanCtx.logger.info('cleanup planning complete', { phase: 'cleanup-plan', filesToDelete: plan.filesToDelete.length, @@ -467,7 +531,10 @@ export async function performCleanup( pluginCount: snapshot.pluginSnapshots.length, outputCount: snapshot.pluginSnapshots.reduce((total, plugin) => total + plugin.outputs.length, 0) }) - const result = await performCleanupWithNative(snapshot) + const result = reconcileExactSafeFileViolations( + await performCleanupWithNative(snapshot), + collectExactSafeFilePaths(snapshot) + ) logger.info('cleanup native execution finished', { phase: 'cleanup-execute', deletedFiles: result.deletedFiles,