setName('tools:agent:publish') ->setDescription('发布 AGENTS.md 与 .agent 到兼容目标(复制 rules/skills;可选生成 CLAUDE.md)') ->addOption('target', 't', Option::VALUE_OPTIONAL, '发布目标:trae|opencode|roocode|cursor|claude|all', 'all') ->addOption('dry-run', null, Option::VALUE_NONE, '只输出将要写入/覆盖的清单,不落盘') ->addOption('clean', null, Option::VALUE_NONE, '清理目标中由发布器生成的内容(仅影响 rules/skills 与发布文件)') ->addOption('force', 'f', Option::VALUE_NONE, '跳过覆盖确认'); } protected function execute(Input $input, Output $output) { $rootPath = rtrim(App::getRootPath(), "\\/ \t\n\r\0\x0B"); $sourceAgentsPath = $rootPath . DIRECTORY_SEPARATOR . 'AGENTS.md'; $sourceRulesDir = $rootPath . DIRECTORY_SEPARATOR . '.agent' . DIRECTORY_SEPARATOR . 'rules'; $sourceSkillsDir = $rootPath . DIRECTORY_SEPARATOR . '.agent' . DIRECTORY_SEPARATOR . 'skills'; if (!is_file($sourceAgentsPath)) { $output->error('缺少单一事实来源文件:' . $sourceAgentsPath); return; } if (!is_dir($sourceRulesDir)) { $output->error('缺少单一事实来源目录:' . $sourceRulesDir); return; } if (!is_dir($sourceSkillsDir)) { $output->error('缺少单一事实来源目录:' . $sourceSkillsDir); return; } $target = strtolower((string)$input->getOption('target')); if ($target === '') { $target = 'all'; } $supportedTargets = ['trae', 'opencode', 'roocode', 'cursor', 'claude', 'all']; if (!in_array($target, $supportedTargets, true)) { $output->error('不支持的 target:' . $target . '(可选:' . implode('|', $supportedTargets) . ')'); return; } $dryRun = (bool)$input->getOption('dry-run'); $clean = (bool)$input->getOption('clean'); $targets = $target === 'all' ? ['trae', 'opencode', 'roocode', 'cursor', 'claude'] : [$target]; $writeActions = []; $deleteActions = []; $agentsContent = file_get_contents($sourceAgentsPath); if ($agentsContent === false) { $output->error('读取失败:' . $sourceAgentsPath); return; } foreach ($targets as $t) { if (in_array($t, ['trae', 'opencode', 'roocode', 'cursor'], true)) { $distRoot = $rootPath . DIRECTORY_SEPARATOR . '.' . $t; $distRules = $distRoot . DIRECTORY_SEPARATOR . 'rules'; $distSkills = $distRoot . DIRECTORY_SEPARATOR . 'skills'; if ($t === 'cursor') { $deleteActions[] = $distRules . DIRECTORY_SEPARATOR . 'ulthon-framework.mdc'; } if ($clean) { $deleteActions[] = $distRules; $deleteActions[] = $distSkills; } $writeActions = array_merge($writeActions, $this->planCopyDir($sourceRulesDir, $distRules)); $writeActions = array_merge($writeActions, $this->planCopyDir($sourceSkillsDir, $distSkills)); } if ($t === 'claude') { $distClaude = $rootPath . DIRECTORY_SEPARATOR . 'CLAUDE.md'; if ($clean) { $deleteActions[] = $distClaude; } $writeActions = array_merge($writeActions, $this->planWriteFile($distClaude, $agentsContent)); } } $deleteActions = array_values(array_unique(array_filter($deleteActions, 'strlen'))); $plannedDeletes = $this->filterExistingDeletes($deleteActions); $plannedWrites = $this->filterEffectiveWrites($writeActions, $clean); $output->writeln(''); $output->writeln('=== tools:agent:publish 计划 ==='); $output->writeln('target:' . $target); $output->writeln('dry-run:' . ($dryRun ? 'yes' : 'no')); $output->writeln('clean:' . ($clean ? 'yes' : 'no')); $output->writeln(''); if (empty($plannedDeletes) && empty($plannedWrites)) { $output->writeln('无变更:目标已是最新状态。'); $output->writeln(''); return; } if (!empty($plannedDeletes)) { $output->writeln('将删除:'); foreach ($plannedDeletes as $path) { $output->writeln(' - ' . $path); } $output->writeln(''); } if (!empty($plannedWrites)) { $output->writeln('将写入:'); foreach ($plannedWrites as $item) { $output->writeln(' - [' . $item['mode'] . '] ' . $item['dist']); } $output->writeln(''); } if ($dryRun) { return; } if (!$output->confirm($input, '将执行上述写入/覆盖/删除操作,是否继续?', true)) { $output->comment('已取消。'); $output->newLine(); return false; } foreach ($plannedDeletes as $path) { $this->deletePath($path); } foreach ($plannedWrites as $item) { $this->ensureDir(dirname($item['dist'])); file_put_contents($item['dist'], $item['content']); } $output->info('发布完成。'); $output->newLine(); } protected function planCopyDir(string $sourceDir, string $distDir): array { $actions = []; if (!is_dir($sourceDir)) { return $actions; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); $sourceDirNormalized = rtrim($sourceDir, "\\/") . DIRECTORY_SEPARATOR; foreach ($iterator as $fileInfo) { if (!$fileInfo->isFile()) { continue; } $src = $fileInfo->getPathname(); $relative = substr($src, strlen($sourceDirNormalized)); $dist = $distDir . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative); $content = file_get_contents($src); if ($content === false) { continue; } $actions[] = [ 'dist' => $dist, 'content' => $content, ]; } return $actions; } protected function planWriteFile(string $dist, string $content): array { return [[ 'dist' => $dist, 'content' => $content, ]]; } protected function filterEffectiveWrites(array $actions, bool $forceWrite = false): array { $result = []; $resultMap = []; foreach ($actions as $item) { $dist = (string)($item['dist'] ?? ''); $content = (string)($item['content'] ?? ''); if ($dist === '') { continue; } $mode = 'create'; if (is_file($dist)) { $existing = file_get_contents($dist); if (!$forceWrite && $existing !== false && $existing === $content) { continue; } $mode = 'update'; } $resultMap[$dist] = [ 'dist' => $dist, 'content' => $content, 'mode' => $mode, ]; } $result = array_values($resultMap); return $result; } protected function filterExistingDeletes(array $deleteActions): array { $result = []; foreach ($deleteActions as $path) { if (is_file($path) || is_dir($path)) { $result[] = $path; } } return $result; } protected function deletePath(string $path): void { if (is_file($path)) { @unlink($path); return; } if (!is_dir($path)) { return; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iterator as $fileInfo) { if ($fileInfo->isDir()) { @rmdir($fileInfo->getPathname()); continue; } @unlink($fileInfo->getPathname()); } @rmdir($path); } protected function ensureDir(string $dir): void { if ($dir === '' || is_dir($dir)) { return; } @mkdir($dir, 0777, true); } }