mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-07-01 23:42:48 +08:00
278 lines
9.1 KiB
PHP
278 lines
9.1 KiB
PHP
<?php
|
||
|
||
namespace base\common\command\tools\agent;
|
||
|
||
use app\common\console\Command;
|
||
use RecursiveDirectoryIterator;
|
||
use RecursiveIteratorIterator;
|
||
use think\console\Input;
|
||
use think\console\input\Option;
|
||
use think\console\Output;
|
||
use think\facade\App;
|
||
|
||
class ToolsAgentPublishBase extends Command
|
||
{
|
||
protected function configure()
|
||
{
|
||
parent::configure();
|
||
|
||
$this->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('<info>=== tools:agent:publish 计划 ===</info>');
|
||
$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('<comment>无变更:目标已是最新状态。</comment>');
|
||
$output->writeln('');
|
||
return;
|
||
}
|
||
|
||
if (!empty($plannedDeletes)) {
|
||
$output->writeln('<comment>将删除:</comment>');
|
||
foreach ($plannedDeletes as $path) {
|
||
$output->writeln(' - ' . $path);
|
||
}
|
||
$output->writeln('');
|
||
}
|
||
|
||
if (!empty($plannedWrites)) {
|
||
$output->writeln('<comment>将写入:</comment>');
|
||
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);
|
||
}
|
||
}
|
||
|