refactor: susun semula struktur folder — Laravel source ke src/
This commit is contained in:
409
vendor/laravel/boost/src/Console/AddSkillCommand.php
vendored
Normal file
409
vendor/laravel/boost/src/Console/AddSkillCommand.php
vendored
Normal file
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Boost\Console;
|
||||
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use InvalidArgumentException;
|
||||
use Laravel\Boost\Concerns\DisplayHelper;
|
||||
use Laravel\Boost\Skills\Remote\AuditResult;
|
||||
use Laravel\Boost\Skills\Remote\GitHubRepository;
|
||||
use Laravel\Boost\Skills\Remote\GitHubSkillProvider;
|
||||
use Laravel\Boost\Skills\Remote\RemoteSkill;
|
||||
use Laravel\Boost\Skills\Remote\SkillAuditor;
|
||||
use Laravel\Prompts\Terminal;
|
||||
use RuntimeException;
|
||||
|
||||
use function Laravel\Prompts\confirm;
|
||||
use function Laravel\Prompts\grid;
|
||||
use function Laravel\Prompts\multiselect;
|
||||
use function Laravel\Prompts\note;
|
||||
use function Laravel\Prompts\spin;
|
||||
use function Laravel\Prompts\table;
|
||||
use function Laravel\Prompts\text;
|
||||
|
||||
class AddSkillCommand extends Command
|
||||
{
|
||||
use DisplayHelper;
|
||||
|
||||
/** @var string */
|
||||
protected $signature = 'boost:add-skill
|
||||
{repo? : GitHub repository (owner/repo or full URL)}
|
||||
{--list : List available skills}
|
||||
{--all : Install all skills}
|
||||
{--skill=* : Specific skills to install}
|
||||
{--force : Overwrite existing skills}
|
||||
{--skip-audit : Skip security audit}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Add skills from a remote GitHub repository';
|
||||
|
||||
protected GitHubRepository $repository;
|
||||
|
||||
protected GitHubSkillProvider $fetcher;
|
||||
|
||||
/** @var Collection<string, RemoteSkill> */
|
||||
protected Collection $availableSkills;
|
||||
|
||||
protected string $defaultSkillsPath = '.ai/skills';
|
||||
|
||||
public function __construct(private readonly Terminal $terminal)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->displayHeader();
|
||||
|
||||
if (! $this->initializeRepository()) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->discoverAvailableSkills()) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return $this->handleAction();
|
||||
}
|
||||
|
||||
protected function initializeRepository(): bool
|
||||
{
|
||||
$repository = $this->parseRepository();
|
||||
|
||||
if (! $repository instanceof GitHubRepository) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->repository = $repository;
|
||||
$this->fetcher = new GitHubSkillProvider($this->repository);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function discoverAvailableSkills(): bool
|
||||
{
|
||||
try {
|
||||
$this->availableSkills = spin(
|
||||
callback: fn (): Collection => $this->fetcher->discoverSkills(),
|
||||
message: "Fetching skills from {$this->repository->source()}..."
|
||||
);
|
||||
} catch (RuntimeException $runtimeException) {
|
||||
$this->error($runtimeException->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->availableSkills->isEmpty()) {
|
||||
$this->error('No valid skills are found in the repository.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function handleAction(): int
|
||||
{
|
||||
if ($this->option('list')) {
|
||||
return $this->displaySkillsTable();
|
||||
}
|
||||
|
||||
return $this->installSkills();
|
||||
}
|
||||
|
||||
protected function parseRepository(): ?GitHubRepository
|
||||
{
|
||||
$input = $this->argument('repo') ??
|
||||
text(
|
||||
label: 'Which GitHub repository would you like to fetch skills from?',
|
||||
placeholder: 'owner/repo or GitHub URL',
|
||||
required: true,
|
||||
validate: function (string $value): ?string {
|
||||
try {
|
||||
GitHubRepository::fromInput($value);
|
||||
|
||||
return null;
|
||||
} catch (InvalidArgumentException $invalidArgumentException) {
|
||||
return $invalidArgumentException->getMessage();
|
||||
}
|
||||
},
|
||||
hint: 'e.g., vercel-labs/agent-skills or https://github.com/owner/repo'
|
||||
);
|
||||
|
||||
return GitHubRepository::fromInput($input);
|
||||
}
|
||||
|
||||
protected function displayHeader(): void
|
||||
{
|
||||
$this->terminal->initDimensions();
|
||||
$this->displayBoostHeader('Skill', config('app.name'));
|
||||
}
|
||||
|
||||
protected function displaySkillsTable(): int
|
||||
{
|
||||
note("Found {$this->availableSkills->count()} available skills");
|
||||
|
||||
grid($this->availableSkills->keys()->sort()->values()->toArray());
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function installSkills(): int
|
||||
{
|
||||
$selectedSkills = $this->selectSkills();
|
||||
|
||||
if ($selectedSkills->isEmpty()) {
|
||||
$this->warn('No skills are selected.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$skillsToInstall = $this->skillsToInstall($selectedSkills);
|
||||
|
||||
if ($skillsToInstall->isEmpty()) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->runAuditBeforeInstall($skillsToInstall)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$results = $this->downloadSkills($skillsToInstall);
|
||||
|
||||
if ($results['installedNames'] !== []) {
|
||||
$this->info('Skills installed:');
|
||||
|
||||
grid($results['installedNames']);
|
||||
|
||||
$this->runBoostUpdate();
|
||||
$this->showOutro();
|
||||
}
|
||||
|
||||
if ($results['failedDetails'] !== []) {
|
||||
$this->error('Some skills failed to install:');
|
||||
|
||||
grid(array_keys($results['failedDetails']));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, RemoteSkill>
|
||||
*/
|
||||
protected function selectSkills(): Collection
|
||||
{
|
||||
if ($this->option('all')) {
|
||||
return $this->availableSkills;
|
||||
}
|
||||
|
||||
/** @var array<int, string> $skillOptions */
|
||||
$skillOptions = $this->option('skill');
|
||||
|
||||
if ($skillOptions !== []) {
|
||||
return $this->availableSkills->only($skillOptions);
|
||||
}
|
||||
|
||||
/** @var array<int, string> $selected */
|
||||
$selected = multiselect(
|
||||
label: 'Which skills would you like to install?',
|
||||
options: $this->availableSkills
|
||||
->mapWithKeys(fn (RemoteSkill $skill): array => [$skill->name => $skill->name])
|
||||
->toArray(),
|
||||
scroll: 10,
|
||||
required: true,
|
||||
hint: 'Use --all to install all skills at once',
|
||||
);
|
||||
|
||||
return $this->availableSkills->only($selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, RemoteSkill> $skills
|
||||
*/
|
||||
protected function skillsToInstall(Collection $skills): Collection
|
||||
{
|
||||
[$existingSkills, $newSkills] = $skills->partition(
|
||||
fn (RemoteSkill $skill): bool => $this->skillExists($skill)
|
||||
);
|
||||
|
||||
if ($existingSkills->isEmpty() || $this->shouldUpdateExisting($existingSkills)) {
|
||||
return $skills;
|
||||
}
|
||||
|
||||
return $newSkills;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, RemoteSkill> $existingSkills
|
||||
*/
|
||||
protected function shouldUpdateExisting(Collection $existingSkills): bool
|
||||
{
|
||||
if ($this->option('force')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! stream_isatty(STDIN)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return confirm(
|
||||
label: "Update {$existingSkills->count()} existing skill(s)?",
|
||||
);
|
||||
}
|
||||
|
||||
protected function skillExists(RemoteSkill $skill): bool
|
||||
{
|
||||
return is_dir($this->skillTargetPath($skill));
|
||||
}
|
||||
|
||||
protected function skillTargetPath(RemoteSkill $skill): string
|
||||
{
|
||||
return base_path($this->defaultSkillsPath.DIRECTORY_SEPARATOR.$skill->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, RemoteSkill> $skills
|
||||
* @return array{installedNames: array<int, string>, failedDetails: array<string, string>}
|
||||
*/
|
||||
protected function downloadSkills(Collection $skills): array
|
||||
{
|
||||
return spin(
|
||||
callback: fn (): array => $this->addSkills($skills),
|
||||
message: 'Downloading skills...'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, RemoteSkill> $skills
|
||||
* @return array{installedNames: array<int, string>, failedDetails: array<string, string>}
|
||||
*/
|
||||
protected function addSkills(Collection $skills): array
|
||||
{
|
||||
$results = ['installedNames' => [], 'failedDetails' => []];
|
||||
|
||||
foreach ($skills as $skill) {
|
||||
$targetPath = $this->skillTargetPath($skill);
|
||||
|
||||
if ($this->skillExists($skill)) {
|
||||
File::deleteDirectory($targetPath);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->fetcher->downloadSkill($skill, $targetPath)) {
|
||||
$results['installedNames'][] = $skill->name;
|
||||
} else {
|
||||
$results['failedDetails'][$skill->name] = 'Download failed';
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
$results['failedDetails'][$skill->name] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, RemoteSkill> $selectedSkills
|
||||
*/
|
||||
protected function runAuditBeforeInstall(Collection $selectedSkills): bool
|
||||
{
|
||||
if ($this->option('skip-audit')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$skillNames = $selectedSkills->keys()->values()->all();
|
||||
|
||||
/** @var array<string, array<int, AuditResult>> $auditResults */
|
||||
$auditResults = spin(
|
||||
callback: fn (): array => (new SkillAuditor)->audit(
|
||||
$this->repository->source(),
|
||||
$skillNames,
|
||||
),
|
||||
message: 'Running security audit...',
|
||||
);
|
||||
|
||||
if (! $this->hasRiskySkills($auditResults)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->displayAuditResults($auditResults, $skillNames);
|
||||
|
||||
if (! stream_isatty(STDIN)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return confirm('Do you want to install these skills?');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, AuditResult>> $auditResults
|
||||
* @param array<int, string> $skillNames
|
||||
*/
|
||||
protected function displayAuditResults(array $auditResults, array $skillNames): void
|
||||
{
|
||||
$partnerKeys = collect($auditResults)
|
||||
->flatMap(fn (array $results): array => array_map(fn (AuditResult $auditResult): string => $auditResult->partner, $results))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$headers = array_merge(['Skill'], array_map(ucfirst(...), $partnerKeys));
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($skillNames as $skillName) {
|
||||
$partnerResults = $auditResults[$skillName] ?? [];
|
||||
$partnerMap = collect($partnerResults)->keyBy(fn (AuditResult $auditResult): string => $auditResult->partner);
|
||||
|
||||
$row = [$skillName];
|
||||
|
||||
foreach ($partnerKeys as $partnerKey) {
|
||||
$row[] = $partnerMap->has($partnerKey)
|
||||
? $this->colorizeRisk($partnerMap->get($partnerKey))
|
||||
: '—';
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
note('Security Audit');
|
||||
table($headers, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, AuditResult>> $auditResults
|
||||
*/
|
||||
protected function hasRiskySkills(array $auditResults): bool
|
||||
{
|
||||
return collect($auditResults)
|
||||
->flatten()
|
||||
->contains(fn (AuditResult $result): bool => $result->risk->weight() >= 3);
|
||||
}
|
||||
|
||||
protected function colorizeRisk(AuditResult $result): string
|
||||
{
|
||||
return match ($result->risk->color()) {
|
||||
'red' => $this->red($result->risk->label()),
|
||||
'yellow' => $this->yellow($result->risk->label()),
|
||||
'green' => $this->green($result->risk->label()),
|
||||
default => $this->dim($result->risk->label()),
|
||||
};
|
||||
}
|
||||
|
||||
protected function runBoostUpdate(): void
|
||||
{
|
||||
$this->callSilently(UpdateCommand::class);
|
||||
}
|
||||
|
||||
protected function showOutro(): void
|
||||
{
|
||||
$this->displayOutro('Enjoy the boost 🚀', terminalWidth: $this->terminal->cols());
|
||||
}
|
||||
}
|
||||
45
vendor/laravel/boost/src/Console/Enums/Theme.php
vendored
Normal file
45
vendor/laravel/boost/src/Console/Enums/Theme.php
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Boost\Console\Enums;
|
||||
|
||||
enum Theme: string
|
||||
{
|
||||
case LaravelRed = 'laravel_red';
|
||||
case Gray = 'gray';
|
||||
case Ocean = 'ocean';
|
||||
case Vaporwave = 'vaporwave';
|
||||
case Sunset = 'sunset';
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function gradient(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::LaravelRed => [196, 160, 124, 88, 52, 88],
|
||||
self::Gray => [250, 248, 245, 243, 240, 238],
|
||||
self::Ocean => [81, 75, 69, 63, 57, 21],
|
||||
self::Vaporwave => [213, 177, 141, 105, 69, 39],
|
||||
self::Sunset => [214, 208, 202, 196, 160, 124],
|
||||
};
|
||||
}
|
||||
|
||||
public function primary(): int
|
||||
{
|
||||
return $this->gradient()[0];
|
||||
}
|
||||
|
||||
public function accent(): int
|
||||
{
|
||||
return $this->gradient()[2];
|
||||
}
|
||||
|
||||
public static function random(): self
|
||||
{
|
||||
$cases = self::cases();
|
||||
|
||||
return $cases[array_rand($cases)];
|
||||
}
|
||||
}
|
||||
79
vendor/laravel/boost/src/Console/ExecuteToolCommand.php
vendored
Normal file
79
vendor/laravel/boost/src/Console/ExecuteToolCommand.php
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Boost\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Laravel\Boost\Mcp\ToolRegistry;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Throwable;
|
||||
|
||||
class ExecuteToolCommand extends Command
|
||||
{
|
||||
protected $signature = 'boost:execute-tool {tool} {arguments}';
|
||||
|
||||
protected $description = 'Execute a Boost MCP tool in isolation (internal command)';
|
||||
|
||||
protected $hidden = true;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$toolClass = $this->argument('tool');
|
||||
$argumentsEncoded = $this->argument('arguments');
|
||||
|
||||
// Validate the tool is registered
|
||||
if (! ToolRegistry::isToolAllowed($toolClass)) {
|
||||
$this->error("Tool not registered or not allowed: {$toolClass}");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Decode arguments
|
||||
$arguments = json_decode(base64_decode($argumentsEncoded, true), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->error('Invalid arguments format: '.json_last_error_msg());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** @var Tool $tool */
|
||||
$tool = app($toolClass);
|
||||
|
||||
$request = new Request($arguments ?? []);
|
||||
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
/** @var Response $response */
|
||||
$response = $tool->handle($request); // @phpstan-ignore-line
|
||||
} catch (Throwable $throwable) {
|
||||
ob_end_clean();
|
||||
|
||||
$errorResult = Response::error("Tool execution failed (E_THROWABLE): {$throwable->getMessage()}");
|
||||
|
||||
$this->error(json_encode([
|
||||
'isError' => true,
|
||||
'content' => [
|
||||
$errorResult->content()->toTool($tool),
|
||||
],
|
||||
]));
|
||||
|
||||
return static::FAILURE;
|
||||
}
|
||||
|
||||
ob_end_clean();
|
||||
|
||||
echo json_encode([
|
||||
'isError' => $response->isError(),
|
||||
'content' => [
|
||||
$response->content()->toTool($tool),
|
||||
],
|
||||
]);
|
||||
|
||||
return static::SUCCESS;
|
||||
}
|
||||
}
|
||||
560
vendor/laravel/boost/src/Console/InstallCommand.php
vendored
Normal file
560
vendor/laravel/boost/src/Console/InstallCommand.php
vendored
Normal file
@@ -0,0 +1,560 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Boost\Console;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Boost\Concerns\DisplayHelper;
|
||||
use Laravel\Boost\Contracts\SupportsGuidelines;
|
||||
use Laravel\Boost\Contracts\SupportsMcp;
|
||||
use Laravel\Boost\Contracts\SupportsSkills;
|
||||
use Laravel\Boost\Install\Agents\Agent;
|
||||
use Laravel\Boost\Install\AgentsDetector;
|
||||
use Laravel\Boost\Install\Cloud;
|
||||
use Laravel\Boost\Install\GuidelineComposer;
|
||||
use Laravel\Boost\Install\GuidelineConfig;
|
||||
use Laravel\Boost\Install\GuidelineWriter;
|
||||
use Laravel\Boost\Install\McpWriter;
|
||||
use Laravel\Boost\Install\Nightwatch;
|
||||
use Laravel\Boost\Install\Sail;
|
||||
use Laravel\Boost\Install\Skill;
|
||||
use Laravel\Boost\Install\SkillComposer;
|
||||
use Laravel\Boost\Install\SkillWriter;
|
||||
use Laravel\Boost\Install\ThirdPartyPackage;
|
||||
use Laravel\Boost\Skills\Remote\GitHubRepository;
|
||||
use Laravel\Boost\Skills\Remote\GitHubSkillProvider;
|
||||
use Laravel\Boost\Skills\Remote\RemoteSkill;
|
||||
use Laravel\Boost\Support\Config;
|
||||
use Laravel\Prompts\Terminal;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
use function Laravel\Prompts\grid;
|
||||
use function Laravel\Prompts\multiselect;
|
||||
|
||||
class InstallCommand extends Command
|
||||
{
|
||||
use DisplayHelper;
|
||||
|
||||
protected $signature = 'boost:install
|
||||
{--guidelines : Install AI guidelines}
|
||||
{--skills : Install agent skills}
|
||||
{--mcp : Install MCP server configuration}';
|
||||
|
||||
/** @var Collection<int, Agent> */
|
||||
private Collection $selectedAgents;
|
||||
|
||||
/** @var Collection<int, string> */
|
||||
private Collection $selectedBoostFeatures;
|
||||
|
||||
/** @var Collection<int, string> */
|
||||
private Collection $selectedThirdPartyPackages;
|
||||
|
||||
private string $projectName;
|
||||
|
||||
/** @var array<non-empty-string> */
|
||||
private array $systemInstalledAgents = [];
|
||||
|
||||
/** @var array<non-empty-string> */
|
||||
private array $projectInstalledAgents = [];
|
||||
|
||||
private bool $enforceTests = true;
|
||||
|
||||
/** @var array<int, string> */
|
||||
private array $installedSkillNames = [];
|
||||
|
||||
const MIN_TEST_COUNT = 6;
|
||||
|
||||
public function __construct(
|
||||
private readonly AgentsDetector $agentsDetector,
|
||||
private readonly Cloud $cloud,
|
||||
private readonly Config $config,
|
||||
private readonly Nightwatch $nightwatch,
|
||||
private readonly Sail $sail,
|
||||
private readonly Terminal $terminal
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->terminal->initDimensions();
|
||||
$this->projectName = config('app.name');
|
||||
|
||||
$this->displayBoostHeader('Install', $this->projectName);
|
||||
$this->discoverEnvironment();
|
||||
$this->collectInstallationPreferences();
|
||||
$this->performInstallation();
|
||||
$this->outro();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function discoverEnvironment(): void
|
||||
{
|
||||
if ($this->config->getAgents() !== []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->systemInstalledAgents = $this->agentsDetector->discoverSystemInstalledAgents();
|
||||
$this->projectInstalledAgents = $this->agentsDetector->discoverProjectInstalledAgents(base_path());
|
||||
}
|
||||
|
||||
protected function collectInstallationPreferences(): void
|
||||
{
|
||||
$this->selectedBoostFeatures = $this->selectBoostFeatures();
|
||||
|
||||
$this->selectedThirdPartyPackages = $this->selectedBoostFeatures->contains('guidelines') || $this->selectedBoostFeatures->contains('skills')
|
||||
? $this->selectThirdPartyPackages()
|
||||
: collect();
|
||||
|
||||
$this->selectIntegrations();
|
||||
|
||||
$this->selectedAgents = $this->selectAgents();
|
||||
$this->enforceTests = $this->selectedBoostFeatures->contains('guidelines') && $this->determineTestEnforcement();
|
||||
}
|
||||
|
||||
protected function performInstallation(): void
|
||||
{
|
||||
app()->instance(GuidelineConfig::class, $this->buildGuidelineConfig());
|
||||
|
||||
if ($this->selectedBoostFeatures->contains('guidelines')) {
|
||||
$this->installGuidelines();
|
||||
}
|
||||
|
||||
if ($this->shouldInstallCloudSkill()) {
|
||||
$this->downloadCloudSkill();
|
||||
}
|
||||
|
||||
if ($this->selectedBoostFeatures->contains('skills')) {
|
||||
$this->installSkills();
|
||||
}
|
||||
|
||||
if ($this->selectedBoostFeatures->contains('mcp')) {
|
||||
$this->installMcpServerConfig();
|
||||
}
|
||||
|
||||
$this->storeConfig();
|
||||
}
|
||||
|
||||
protected function outro(): void
|
||||
{
|
||||
$url = 'https://laravel.com/docs/boost';
|
||||
$link = $this->hyperlink($url, $url);
|
||||
$text = 'Enjoy the boost 🚀 Next steps: ';
|
||||
|
||||
$this->displayOutro($text, $link, $this->terminal->cols());
|
||||
}
|
||||
|
||||
/**
|
||||
* We shouldn't add an AI guideline enforcing test if they don't have a basic test setup.
|
||||
* This would likely just create headaches for them or be a waste of time as they
|
||||
* won't have the CI setup to make use of them anyway, so we're just wasting their
|
||||
* tokens/money by enforcing them.
|
||||
*/
|
||||
protected function determineTestEnforcement(): bool
|
||||
{
|
||||
if (config('boost.enforce_tests') !== null) {
|
||||
return (bool) config('boost.enforce_tests');
|
||||
}
|
||||
|
||||
if (! file_exists(base_path('vendor/bin/phpunit'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$process = new Process([PHP_BINARY, 'artisan', 'test', '--list-tests'], base_path());
|
||||
$process->run();
|
||||
|
||||
return Str::of($process->getOutput())
|
||||
->trim()
|
||||
->explode("\n")
|
||||
->filter(fn ($line): bool => str_contains($line, '::'))
|
||||
->count() >= self::MIN_TEST_COUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
protected function selectBoostFeatures(): Collection
|
||||
{
|
||||
$featureLabels = collect([
|
||||
'guidelines' => 'AI Guidelines',
|
||||
'skills' => 'Agent Skills',
|
||||
'mcp' => 'Boost MCP Server Configuration',
|
||||
]);
|
||||
|
||||
$explicit = $featureLabels->keys()->filter(fn ($feature) => $this->option($feature));
|
||||
|
||||
if ($explicit->isNotEmpty()) {
|
||||
return $explicit->values();
|
||||
}
|
||||
|
||||
$configValues = collect([
|
||||
'guidelines' => $this->config->getGuidelines(),
|
||||
'skills' => $this->config->hasSkills(),
|
||||
'mcp' => $this->config->getMcp(),
|
||||
]);
|
||||
|
||||
$defaults = $configValues->filter()->keys()->whenEmpty(fn () => $featureLabels->keys());
|
||||
|
||||
return collect(multiselect(
|
||||
label: 'Which Boost features would you like to configure?',
|
||||
options: $featureLabels->all(),
|
||||
default: $defaults->all(),
|
||||
required: true,
|
||||
hint: 'This will override the current guidelines, skills, and MCP configuration',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
protected function selectThirdPartyPackages(): Collection
|
||||
{
|
||||
$packages = ThirdPartyPackage::discover();
|
||||
|
||||
if ($packages->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return collect(multiselect(
|
||||
label: 'Which third-party AI guidelines/skills would you like to install?',
|
||||
options: $packages->mapWithKeys(fn (ThirdPartyPackage $pkg, string $name): array => [
|
||||
$name => $pkg->displayLabel(),
|
||||
])->toArray(),
|
||||
default: collect($this->config->getPackages())
|
||||
->filter(fn (string $name) => $packages->has($name))
|
||||
->values(),
|
||||
scroll: 10,
|
||||
hint: 'You can add or remove them later by running this command again',
|
||||
));
|
||||
}
|
||||
|
||||
protected function selectIntegrations(): void
|
||||
{
|
||||
$integrations = collect([
|
||||
'cloud' => [
|
||||
'label' => 'Laravel Cloud',
|
||||
'available' => true,
|
||||
'default' => $this->config->getCloud(),
|
||||
],
|
||||
'nightwatch' => [
|
||||
'label' => 'Laravel Nightwatch',
|
||||
'available' => $this->nightwatch->isInstalled(),
|
||||
'default' => $this->config->getNightwatch(),
|
||||
],
|
||||
'sail' => [
|
||||
'label' => 'Laravel Sail',
|
||||
'available' => $this->sail->isInstalled(),
|
||||
'default' => $this->sail->isActive() || $this->config->getSail(),
|
||||
],
|
||||
])->filter(fn (array $integration): bool => $integration['available']);
|
||||
|
||||
$selected = multiselect(
|
||||
label: 'Which integrations would you like to configure for Boost?',
|
||||
options: $integrations->map(fn (array $integration): string => $integration['label'])->all(),
|
||||
default: $integrations->filter(fn (array $integration): bool => $integration['default'])->keys()->all(),
|
||||
hint: 'Selected integrations will have their MCP servers or skills automatically configured',
|
||||
);
|
||||
|
||||
$this->selectedBoostFeatures->push(...$selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Agent>
|
||||
*/
|
||||
protected function selectAgents(): Collection
|
||||
{
|
||||
$allAgents = $this->agentsDetector->getAgents();
|
||||
|
||||
if ($allAgents->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$featureInterfaces = [
|
||||
'guidelines' => SupportsGuidelines::class,
|
||||
'skills' => SupportsSkills::class,
|
||||
'mcp' => SupportsMcp::class,
|
||||
];
|
||||
|
||||
$filteredAgents = $allAgents->filter(
|
||||
fn (Agent $agent): bool => $this->selectedBoostFeatures->contains(
|
||||
fn ($feature): bool => isset($featureInterfaces[$feature]) && $agent instanceof $featureInterfaces[$feature])
|
||||
)->keyBy(fn (Agent $agent): string => $agent->name());
|
||||
|
||||
if ($filteredAgents->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$options = $filteredAgents
|
||||
->mapWithKeys(fn (Agent $agent): array => [$agent->name() => $agent->displayName()])
|
||||
->sort();
|
||||
|
||||
$defaults = collect($this->config->getAgents())
|
||||
->filter(fn (string $name) => $filteredAgents->has($name))
|
||||
->whenEmpty(fn () => collect([...$this->projectInstalledAgents, ...$this->systemInstalledAgents])
|
||||
->unique()
|
||||
->filter(fn (string $name) => $filteredAgents->has($name))
|
||||
)
|
||||
->values();
|
||||
|
||||
$selected = multiselect(
|
||||
label: 'Which AI agents would you like to configure?',
|
||||
options: $options->all(),
|
||||
default: $defaults->all(),
|
||||
scroll: $options->count(),
|
||||
required: true,
|
||||
);
|
||||
|
||||
return collect($selected)
|
||||
->map(fn (string $name) => $filteredAgents->get($name))
|
||||
->filter()
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Agent&SupportsMcp>
|
||||
*/
|
||||
protected function agentsWithMcp(): Collection
|
||||
{
|
||||
return $this->selectedAgents->filter(fn (Agent $a): bool => $a instanceof SupportsMcp);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Agent&SupportsGuidelines>
|
||||
*/
|
||||
protected function agentsWithGuidelines(): Collection
|
||||
{
|
||||
return $this->selectedAgents->filter(fn (Agent $a): bool => $a instanceof SupportsGuidelines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Agent&SupportsSkills>
|
||||
*/
|
||||
protected function agentsWithSkills(): Collection
|
||||
{
|
||||
return $this->selectedAgents->filter(fn (Agent $a): bool => $a instanceof SupportsSkills);
|
||||
}
|
||||
|
||||
protected function installGuidelines(): void
|
||||
{
|
||||
$guidelinesAgents = $this->agentsWithGuidelines();
|
||||
$composer = app(GuidelineComposer::class)->config($this->buildGuidelineConfig());
|
||||
$guidelines = $composer->guidelines();
|
||||
$composedAiGuidelines = $composer->compose();
|
||||
|
||||
$this->installFeature(
|
||||
agents: $guidelinesAgents,
|
||||
emptyMessage: 'No agents are selected for guideline installation.',
|
||||
headerMessage: sprintf('Adding %d guidelines to your selected agents', $guidelines->count()),
|
||||
nameResolver: fn (Agent $agent): string => $agent->displayName(),
|
||||
processor: fn (Agent&SupportsGuidelines $agent): int => (new GuidelineWriter($agent))->write($composedAiGuidelines),
|
||||
featureName: 'guidelines',
|
||||
beforeProcess: fn () => grid($guidelines->map(fn ($guideline, string $key): string => $key.($guideline['custom'] ? '*' : ''))->sort()->values()->toArray()),
|
||||
withDelay: true,
|
||||
);
|
||||
}
|
||||
|
||||
protected function installSkills(): void
|
||||
{
|
||||
$skillsAgents = $this->agentsWithSkills();
|
||||
$skillsComposer = app(SkillComposer::class)->config($this->buildGuidelineConfig());
|
||||
$skills = $skillsComposer->skills();
|
||||
|
||||
$this->installedSkillNames = $skills->keys()->toArray();
|
||||
|
||||
/** @var Collection<int, SupportsSkills&Agent> $skillsAgents */
|
||||
$this->installFeature(
|
||||
agents: $skillsAgents,
|
||||
emptyMessage: 'No agents are selected for skill installation.',
|
||||
headerMessage: sprintf('Syncing %d skills for skills-capable agents', $skills->count()),
|
||||
nameResolver: fn (SupportsSkills&Agent $agent): string => $agent->displayName(),
|
||||
processor: fn (SupportsSkills&Agent $agent): array => (new SkillWriter($agent))->sync($skills, $this->config->getSkills()),
|
||||
featureName: 'skills',
|
||||
beforeProcess: $skills->isNotEmpty()
|
||||
? fn () => grid($skills->map(fn (Skill $skill): string => $skill->displayName())->sort()->values()->toArray())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function buildGuidelineConfig(): GuidelineConfig
|
||||
{
|
||||
$guidelineConfig = new GuidelineConfig;
|
||||
$guidelineConfig->enforceTests = $this->enforceTests;
|
||||
$guidelineConfig->hasAnApi = false;
|
||||
$guidelineConfig->aiGuidelines = $this->selectedThirdPartyPackages->values()->toArray();
|
||||
$guidelineConfig->usesSail = $this->shouldUseSail();
|
||||
$guidelineConfig->hasSkills = $this->selectedBoostFeatures->contains('skills');
|
||||
$guidelineConfig->hasMcp = $this->selectedBoostFeatures->contains('mcp') || ($this->isExplicitFlagMode() && $this->config->getMcp());
|
||||
|
||||
return $guidelineConfig;
|
||||
}
|
||||
|
||||
protected function shouldInstallCloudSkill(): bool
|
||||
{
|
||||
return $this->selectedBoostFeatures->contains('cloud');
|
||||
}
|
||||
|
||||
protected function downloadCloudSkill(): void
|
||||
{
|
||||
try {
|
||||
$repository = GitHubRepository::fromInput($this->cloud->skillRepo().'/'.$this->cloud->skillPath());
|
||||
$provider = new GitHubSkillProvider($repository);
|
||||
$skill = $provider->discoverSkills()->get($this->cloud->skillName());
|
||||
|
||||
if (! $skill instanceof RemoteSkill) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider->downloadSkill($skill, base_path('.ai/skills/'.$this->cloud->skillName()));
|
||||
} catch (Exception $exception) {
|
||||
$this->warn('Failed to download Cloud skill: '.$exception->getMessage());
|
||||
$this->line('You can install it later with: php artisan boost:add-skill '.$this->cloud->skillRepo());
|
||||
}
|
||||
}
|
||||
|
||||
protected function storeConfig(): void
|
||||
{
|
||||
$explicitMode = $this->isExplicitFlagMode();
|
||||
|
||||
if (! $explicitMode) {
|
||||
$this->config->flush();
|
||||
$this->config->setAgents($this->selectedAgents->map(fn (Agent $agent): string => $agent->name())->values()->toArray());
|
||||
$this->config->setPackages($this->selectedThirdPartyPackages->values()->toArray());
|
||||
} elseif ($this->selectedBoostFeatures->contains('guidelines') || $this->selectedBoostFeatures->contains('skills')) {
|
||||
$this->config->setPackages($this->selectedThirdPartyPackages->values()->toArray());
|
||||
}
|
||||
|
||||
if ($this->selectedBoostFeatures->contains('guidelines')) {
|
||||
$this->config->setGuidelines(true);
|
||||
}
|
||||
|
||||
if ($this->selectedBoostFeatures->contains('skills')) {
|
||||
$this->config->setSkills($this->installedSkillNames);
|
||||
}
|
||||
|
||||
$this->config->setCloud($this->selectedBoostFeatures->contains('cloud'));
|
||||
|
||||
if ($this->selectedBoostFeatures->contains('mcp')) {
|
||||
$this->config->setMcp(true);
|
||||
$this->config->setSail($this->shouldUseSail());
|
||||
$this->config->setNightwatch($this->shouldInstallNightwatchMcp());
|
||||
}
|
||||
}
|
||||
|
||||
protected function shouldInstallNightwatchMcp(): bool
|
||||
{
|
||||
return $this->selectedBoostFeatures->contains('nightwatch');
|
||||
}
|
||||
|
||||
protected function shouldUseSail(): bool
|
||||
{
|
||||
if ($this->selectedBoostFeatures->contains('mcp')) {
|
||||
return $this->selectedBoostFeatures->contains('sail');
|
||||
}
|
||||
|
||||
return $this->config->getSail();
|
||||
}
|
||||
|
||||
protected function isExplicitFlagMode(): bool
|
||||
{
|
||||
if ($this->option('guidelines')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->option('skills')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) $this->option('mcp');
|
||||
}
|
||||
|
||||
protected function installMcpServerConfig(): void
|
||||
{
|
||||
$this->installFeature(
|
||||
agents: $this->agentsWithMcp(),
|
||||
emptyMessage: 'No agents are selected for MCP installation.',
|
||||
headerMessage: 'Installing MCP servers to your selected Agents',
|
||||
nameResolver: fn (Agent $agent): string => $agent->displayName(),
|
||||
processor: fn (Agent&SupportsMcp $agent): int => (new McpWriter($agent))->write(
|
||||
$this->shouldUseSail() ? $this->sail : null,
|
||||
$this->shouldInstallNightwatchMcp() ? $this->nightwatch : null
|
||||
),
|
||||
featureName: 'MCP servers',
|
||||
withDelay: true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
*
|
||||
* @param Collection<int, T> $agents
|
||||
* @param callable(T): string $nameResolver
|
||||
* @param callable(T): mixed $processor
|
||||
* @param ?callable(): void $beforeProcess
|
||||
*/
|
||||
protected function installFeature(
|
||||
Collection $agents,
|
||||
string $emptyMessage,
|
||||
string $headerMessage,
|
||||
callable $nameResolver,
|
||||
callable $processor,
|
||||
string $featureName,
|
||||
?callable $beforeProcess = null,
|
||||
bool $withDelay = false,
|
||||
): void {
|
||||
if ($agents->isEmpty()) {
|
||||
$this->info($emptyMessage);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info($headerMessage);
|
||||
|
||||
if ($beforeProcess !== null) {
|
||||
$beforeProcess();
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
if ($withDelay) {
|
||||
usleep(750000);
|
||||
}
|
||||
|
||||
$failed = [];
|
||||
$nameMap = $agents->map(fn ($agent): string => $nameResolver($agent));
|
||||
$longestName = $nameMap->map(fn (string $name) => Str::length($name))->max() ?? 0;
|
||||
|
||||
foreach ($agents as $index => $agent) {
|
||||
$name = $nameMap[$index];
|
||||
$this->output->write(' '.str_pad($name, $longestName).'... ');
|
||||
|
||||
try {
|
||||
$processor($agent);
|
||||
$this->line($this->green('✓'));
|
||||
} catch (Exception $e) {
|
||||
$failed[$name] = $e->getMessage();
|
||||
$this->line($this->red('✗'));
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed !== []) {
|
||||
$this->newLine();
|
||||
$this->error(sprintf('✗ Failed to install %s to %d agent%s:',
|
||||
$featureName,
|
||||
count($failed),
|
||||
count($failed) === 1 ? '' : 's'
|
||||
));
|
||||
|
||||
foreach ($failed as $agentName => $error) {
|
||||
$this->line(" - {$agentName}: {$error}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
59
vendor/laravel/boost/src/Console/ListSkillCommand.php
vendored
Normal file
59
vendor/laravel/boost/src/Console/ListSkillCommand.php
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Boost\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Boost\Concerns\DisplayHelper;
|
||||
use Laravel\Boost\Install\SkillComposer;
|
||||
|
||||
use function Laravel\Prompts\note;
|
||||
use function Laravel\Prompts\table;
|
||||
|
||||
class ListSkillCommand extends Command
|
||||
{
|
||||
use DisplayHelper;
|
||||
|
||||
protected $signature = 'boost:list-skills';
|
||||
|
||||
protected $description = 'List all available skills in the current project';
|
||||
|
||||
public function handle(SkillComposer $skillComposer): int
|
||||
{
|
||||
$skills = $skillComposer->skills();
|
||||
|
||||
if ($skills->isEmpty()) {
|
||||
$this->info('No skills available in this project.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->displayBoostHeader('Skills', config('app.name'));
|
||||
|
||||
$count = $skills->count();
|
||||
note("Found {$count} skill".($count === 1 ? '' : 's'));
|
||||
|
||||
$this->displaySkillsTable($skills);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function displaySkillsTable(Collection $skills): void
|
||||
{
|
||||
$rows = $skills
|
||||
->sortBy(fn ($skill) => $skill->name)
|
||||
->map(fn ($skill): array => $skill->custom
|
||||
? [$this->dim($skill->name.'*'), $this->yellow('local')]
|
||||
: [$skill->name, $this->dim($skill->package)]
|
||||
)
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
table(
|
||||
headers: ['Skill', 'Source'],
|
||||
rows: $rows
|
||||
);
|
||||
}
|
||||
}
|
||||
18
vendor/laravel/boost/src/Console/StartCommand.php
vendored
Normal file
18
vendor/laravel/boost/src/Console/StartCommand.php
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Boost\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
#[AsCommand('boost:mcp', 'Starts Laravel Boost (usually from mcp.json)')]
|
||||
class StartCommand extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
return Artisan::call('mcp:start laravel-boost');
|
||||
}
|
||||
}
|
||||
85
vendor/laravel/boost/src/Console/UpdateCommand.php
vendored
Normal file
85
vendor/laravel/boost/src/Console/UpdateCommand.php
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Boost\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Boost\Install\ThirdPartyPackage;
|
||||
use Laravel\Boost\Support\Config;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
use function Laravel\Prompts\multiselect;
|
||||
|
||||
#[AsCommand('boost:update', 'Update the Laravel Boost guidelines & skills to the latest guidance')]
|
||||
class UpdateCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'boost:update
|
||||
{--discover : Discover and prompt for newly available guidelines and skills}
|
||||
{--ignore-skills : Skip updating the skills directory}';
|
||||
|
||||
public function handle(Config $config): int
|
||||
{
|
||||
if (! $config->isValid() || empty($config->getAgents())) {
|
||||
$this->error('Please set up Boost with [php artisan boost:install] first.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('discover')) {
|
||||
$this->discoverNewContent($config);
|
||||
}
|
||||
|
||||
$guidelines = $config->getGuidelines();
|
||||
$hasSkills = ! $this->option('ignore-skills') && ($config->hasSkills() || is_dir(base_path('.ai/skills')));
|
||||
|
||||
if (! $guidelines && ! $hasSkills) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->callSilently(InstallCommand::class, [
|
||||
'--no-interaction' => true,
|
||||
'--guidelines' => $guidelines,
|
||||
'--skills' => $hasSkills,
|
||||
]);
|
||||
|
||||
$this->info('Boost guidelines and skills updated successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function discoverNewContent(Config $config): void
|
||||
{
|
||||
$newPackages = $this->resolveNewPackages($config);
|
||||
|
||||
if ($newPackages->isNotEmpty()) {
|
||||
/** @var array<int, string> $selectedPackages */
|
||||
$selectedPackages = multiselect(
|
||||
label: 'New packages with guidelines/skills discovered! Which would you like to add?',
|
||||
options: $newPackages
|
||||
->mapWithKeys(fn (ThirdPartyPackage $pkg, string $name): array => [$name => $pkg->displayLabel()])
|
||||
->toArray(),
|
||||
scroll: 10,
|
||||
required: false,
|
||||
hint: 'Select packages to include their guidelines and skills',
|
||||
);
|
||||
|
||||
if ($selectedPackages !== []) {
|
||||
$config->setPackages(array_merge($config->getPackages(), $selectedPackages));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, ThirdPartyPackage>
|
||||
*/
|
||||
protected function resolveNewPackages(Config $config): Collection
|
||||
{
|
||||
$configuredPackages = $config->getPackages();
|
||||
|
||||
return ThirdPartyPackage::discover()
|
||||
->filter(fn (ThirdPartyPackage $pkg, string $name): bool => ! in_array($name, $configuredPackages, true));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user