refactor: susun semula struktur folder — Laravel source ke src/

This commit is contained in:
Saufi
2026-05-19 15:58:35 +08:00
parent f052251b94
commit bf53c71b45
10806 changed files with 1385379 additions and 121 deletions

View 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());
}
}

View 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)];
}
}

View 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;
}
}

View 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();
}
}

View 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
);
}
}

View 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');
}
}

View 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));
}
}