Files
ars-backend/app/Services/Tool/Tools/LsTool.php
Roog 71226c255b 添加新的工具功能和测试覆盖:
- 注册 `LsTool` 和 `BashTool` 工具,支持目录操作和命令执行
- 增强工具调用逻辑,添加日志记录以提升调试能力
- 增加 `ToolRegistry` 和 `RunLoop` 的增量累积与排序优化
- 完善单元测试覆盖新工具的执行与行为验证
2025-12-23 17:26:27 +08:00

257 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Tool\Tools;
use App\Services\Tool\Tool;
use FilesystemIterator;
use InvalidArgumentException;
use SplFileInfo;
class LsTool implements Tool
{
public function name(): string
{
return 'ls';
}
public function description(): string
{
return '列出指定目录下的文件与目录(支持过滤、排序与详情输出)。';
}
/**
* @return array<string, mixed>
*/
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'directory' => [
'type' => 'string',
'description' => '要列出的目录路径,默认为当前目录。',
'default' => '.',
],
'include_hidden' => [
'type' => 'boolean',
'description' => '是否包含以 . 开头的隐藏文件/目录(默认包含,兼容旧行为)。',
'default' => true,
],
'filter' => [
'type' => 'string',
'description' => '过滤类型all/files/directories。',
'enum' => ['all', 'files', 'directories'],
'default' => 'all',
],
'match' => [
'type' => 'string',
'description' => '可选的通配符匹配fnmatch例如*.php。',
],
'sort' => [
'type' => 'string',
'description' => '排序name_asc/name_desc/mtime_asc/mtime_desc。',
'enum' => ['name_asc', 'name_desc', 'mtime_asc', 'mtime_desc'],
'default' => 'name_asc',
],
'details' => [
'type' => 'boolean',
'description' => '是否返回条目详情name/type/size/modified_at。',
'default' => false,
],
'limit' => [
'type' => 'integer',
'description' => '最多返回条目数量1-1000默认 200。',
'minimum' => 1,
'maximum' => 1000,
'default' => 200,
],
],
'required' => [],
];
}
/**
* @param array<string, mixed> $arguments
* @return array<string, mixed>
*/
public function execute(array $arguments): array
{
$directory = $this->stringOrDefault($arguments, 'directory', '.');
if (! is_dir($directory) || ! is_readable($directory)) {
throw new InvalidArgumentException('目录不存在或不可读:'.$directory);
}
$includeHidden = $this->boolOrDefault($arguments, 'include_hidden', true);
$details = $this->boolOrDefault($arguments, 'details', false);
$filter = $this->enumOrDefault($arguments, 'filter', ['all', 'files', 'directories'], 'all');
$sort = $this->enumOrDefault($arguments, 'sort', ['name_asc', 'name_desc', 'mtime_asc', 'mtime_desc'], 'name_asc');
$match = $this->nullableString($arguments, 'match');
$limit = $this->intOrDefault($arguments, 'limit', 200);
$limit = max(1, min(1000, $limit));
$rawEntries = $this->collectEntries($directory, $includeHidden, $filter, $match);
$rawEntries = $this->sortEntries($rawEntries, $sort);
$rawEntries = array_slice($rawEntries, 0, $limit);
$entries = $details
? array_map(fn (array $entry) => $entry, $rawEntries)
: array_map(fn (array $entry) => $entry['name'], $rawEntries);
return [
'directory' => $directory,
'entries' => array_values($entries),
];
}
/**
* @return array<int, array{name: string, type: string, size: int|null, modified_at: int}>
*/
private function collectEntries(string $directory, bool $includeHidden, string $filter, ?string $match): array
{
$entries = [];
try {
$iterator = new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS);
} catch (\Throwable $exception) {
throw new InvalidArgumentException('无法读取目录:'.$directory, 0, $exception);
}
foreach ($iterator as $fileInfo) {
if (! $fileInfo instanceof SplFileInfo) {
continue;
}
$name = $fileInfo->getFilename();
if (! $includeHidden && str_starts_with($name, '.')) {
continue;
}
if ($filter === 'files' && ! $fileInfo->isFile()) {
continue;
}
if ($filter === 'directories' && ! $fileInfo->isDir()) {
continue;
}
if (is_string($match) && $match !== '' && ! fnmatch($match, $name)) {
continue;
}
$entries[] = [
'name' => $name,
'type' => $this->entryType($fileInfo),
'size' => $fileInfo->isFile() ? $fileInfo->getSize() : null,
'modified_at' => $fileInfo->getMTime(),
];
}
return $entries;
}
/**
* @param array<int, array{name: string, type: string, size: int|null, modified_at: int}> $entries
* @return array<int, array{name: string, type: string, size: int|null, modified_at: int}>
*/
private function sortEntries(array $entries, string $sort): array
{
usort($entries, function (array $left, array $right) use ($sort) {
if ($sort === 'mtime_asc' || $sort === 'mtime_desc') {
$compare = $left['modified_at'] <=> $right['modified_at'];
} else {
$compare = strnatcasecmp($left['name'], $right['name']);
}
if ($compare === 0) {
$compare = strnatcasecmp($left['name'], $right['name']);
}
return ($sort === 'name_desc' || $sort === 'mtime_desc') ? -$compare : $compare;
});
return $entries;
}
private function entryType(SplFileInfo $fileInfo): string
{
if ($fileInfo->isLink()) {
return 'link';
}
if ($fileInfo->isDir()) {
return 'directory';
}
if ($fileInfo->isFile()) {
return 'file';
}
return 'other';
}
/**
* @param array<string, mixed> $arguments
*/
private function stringOrDefault(array $arguments, string $key, string $default): string
{
$value = $arguments[$key] ?? null;
if ($value === null) {
return $default;
}
if (! is_string($value) || trim($value) === '') {
throw new InvalidArgumentException($key.' 必须是非空字符串');
}
return $value;
}
/**
* @param array<string, mixed> $arguments
* @param array<int, string> $allowed
*/
private function enumOrDefault(array $arguments, string $key, array $allowed, string $default): string
{
$value = $arguments[$key] ?? null;
if (! is_string($value)) {
return $default;
}
return in_array($value, $allowed, true) ? $value : $default;
}
/**
* @param array<string, mixed> $arguments
*/
private function boolOrDefault(array $arguments, string $key, bool $default): bool
{
$value = $arguments[$key] ?? null;
return is_bool($value) ? $value : $default;
}
/**
* @param array<string, mixed> $arguments
*/
private function intOrDefault(array $arguments, string $key, int $default): int
{
$value = $arguments[$key] ?? null;
return is_int($value) ? $value : $default;
}
/**
* @param array<string, mixed> $arguments
*/
private function nullableString(array $arguments, string $key): ?string
{
$value = $arguments[$key] ?? null;
return is_string($value) ? $value : null;
}
}