Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/Console/AddSkillCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Laravel\Boost\Skills\Remote\GitHubSkillProvider;
use Laravel\Boost\Skills\Remote\RemoteSkill;
use Laravel\Boost\Skills\Remote\SkillAuditor;
use Laravel\Boost\Support\Config;
use Laravel\Prompts\Terminal;
use RuntimeException;

Expand Down Expand Up @@ -52,8 +53,10 @@ class AddSkillCommand extends Command

protected string $defaultSkillsPath = '.ai/skills';

public function __construct(private readonly Terminal $terminal)
{
public function __construct(
private readonly Terminal $terminal,
private readonly Config $config,
) {
parent::__construct();
}

Expand Down Expand Up @@ -287,6 +290,7 @@ protected function downloadSkills(Collection $skills): array
protected function addSkills(Collection $skills): array
{
$results = ['installedNames' => [], 'failedDetails' => []];
$skillsToTrack = [];

foreach ($skills as $skill) {
$targetPath = $this->skillTargetPath($skill);
Expand All @@ -298,6 +302,14 @@ protected function addSkills(Collection $skills): array
try {
if ($this->fetcher->downloadSkill($skill, $targetPath)) {
$results['installedNames'][] = $skill->name;
$skillsToTrack[$skill->name] = [
'source' => Config::SKILL_SOURCE_GITHUB,
'repo' => $skill->repo,
];

if ($skill->path !== $skill->name) {
$skillsToTrack[$skill->name]['path'] = $skill->path;
}
} else {
$results['failedDetails'][$skill->name] = 'Download failed';
}
Expand All @@ -306,6 +318,10 @@ protected function addSkills(Collection $skills): array
}
}

if ($skillsToTrack !== []) {
$this->config->trackSkills($skillsToTrack);
}

return $results;
}

Expand Down
12 changes: 11 additions & 1 deletion src/Console/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ class InstallCommand extends Command
/** @var array<int, string> */
private array $installedSkillNames = [];

/** @var array<string, array<string, string>> */
private array $installedSkillMetadata = [];

const MIN_TEST_COUNT = 6;

public function __construct(
Expand Down Expand Up @@ -365,6 +368,13 @@ protected function installSkills(): void
$skills = $skillsComposer->skills();

$this->installedSkillNames = $skills->keys()->toArray();
$this->installedSkillMetadata = $skills
->mapWithKeys(fn (Skill $skill): array => [
$skill->name => [
'source' => $skill->custom ? Config::SKILL_SOURCE_CUSTOM : Config::SKILL_SOURCE_OFFICIAL,
],
])
->all();

/** @var Collection<int, SupportsSkills&Agent> $skillsAgents */
$this->installFeature(
Expand Down Expand Up @@ -433,7 +443,7 @@ protected function storeConfig(): void
}

if ($this->selectedBoostFeatures->contains('skills')) {
$this->config->setSkills($this->installedSkillNames);
$this->config->setSkills($this->installedSkillNames, $this->installedSkillMetadata);
}

$this->config->setCloud($this->selectedBoostFeatures->contains('cloud'));
Expand Down
237 changes: 234 additions & 3 deletions src/Support/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ class Config
{
protected const FILE = 'boost.json';

public const SKILL_SOURCE_CUSTOM = 'custom';

public const SKILL_SOURCE_GITHUB = 'github';

public const SKILL_SOURCE_OFFICIAL = 'official';

protected const LEGACY_OFFICIAL_SKILLS_SOURCE = 'laravel/boost';

public function getGuidelines(): bool
{
return (bool) $this->get('guidelines', false);
Expand All @@ -25,22 +33,238 @@ public function setGuidelines(bool $enabled): void
*/
public function getSkills(): array
{
return $this->get('skills', []);
return array_keys($this->getSkillMetadata());
}

/**
* @param array<int, string> $skills
* @param array<string, array<string, string>> $metadata
*/
public function setSkills(array $skills): void
public function setSkills(array $skills, array $metadata = []): void
{
$this->set('skills', $skills);
$currentMetadata = $this->getSkillMetadata();
$selectedSkills = [];

foreach ($skills as $skillName) {
if ($skillName === '') {
continue;
}

$selectedSkills[$skillName] = $this->resolveSkillMetadata(
skillName: $skillName,
currentMetadata: $currentMetadata[$skillName] ?? [],
incomingMetadata: $metadata[$skillName] ?? $currentMetadata[$skillName] ?? ['source' => self::SKILL_SOURCE_OFFICIAL],
);
}

$this->set('skills', $this->sortSkillsByName($selectedSkills));
}

public function hasSkills(): bool
{
return $this->getSkills() !== [];
}

/**
* @return array<string, array<string, string>>
*/
public function getTrackedSkills(): array
{
return $this->getSkillMetadata();
}

/**
* @return array<string, array<string, string>>
*/
public function getSkillMetadata(): array
{
return $this->extractSkillMetadata($this->getRawSkills());
}

/**
* @param array<string, array<string, string>|string> $skills
*/
public function trackSkills(array $skills): void
{
$metadata = $this->getSkillMetadata();

foreach ($skills as $skillName => $skillMetadata) {
if ($skillName === '') {
continue;
}

$metadata[$skillName] = $this->normalizeSkillMetadata($skillName, $skillMetadata);
}

$this->set('skills', $this->sortSkillsByName($metadata));
}

public function trackSkill(string $skillName, string $source): void
{
$this->trackSkills([$skillName => $source]);
}

protected function extractSkillMetadata(array $currentConfig): array
{
if (array_is_list($currentConfig)) {
$metadata = [];

foreach ($currentConfig as $value) {
if (is_string($value) && $value !== '') {
$metadata[$value] = ['source' => self::SKILL_SOURCE_CUSTOM];
}
}

return $metadata;
}

$metadata = [];

foreach ($currentConfig as $key => $value) {
if (is_array($value) && array_is_list($value)) {
$source = is_string($key) && $key !== '' ? $key : self::SKILL_SOURCE_CUSTOM;

foreach ($value as $skillName) {
if (is_string($skillName) && $skillName !== '') {
$metadata[$skillName] = $this->metadataFromLegacySource($skillName, $source);
}
}

continue;
}

if (! is_string($key) || $key === '') {
continue;
}

if (is_bool($value)) {
$metadata[$key] = ['source' => $value ? self::SKILL_SOURCE_OFFICIAL : self::SKILL_SOURCE_CUSTOM];

continue;
}

if (is_string($value)) {
$metadata[$key] = $this->metadataFromLegacySource($key, $value);

continue;
}

if (is_array($value)) {
$metadata[$key] = $this->normalizeSkillMetadata($key, $value);
}
}

return $metadata;
}

/**
* @param array<string, mixed>|string $metadata
* @return array<string, string>
*/
protected function normalizeSkillMetadata(string $skillName, array|string $metadata): array
{
if (is_string($metadata)) {
return $this->metadataFromLegacySource($skillName, $metadata);
}

$source = $metadata['source'] ?? self::SKILL_SOURCE_CUSTOM;

if (! is_string($source) || $source === '') {
return ['source' => self::SKILL_SOURCE_CUSTOM];
}

if ($source === self::SKILL_SOURCE_GITHUB) {
return $this->githubMetadata(
skillName: $skillName,
repo: $metadata['repo'] ?? null,
path: $metadata['path'] ?? null,
);
}

return $this->metadataFromLegacySource($skillName, $source);
}

/**
* @return array<string, string>
*/
protected function metadataFromLegacySource(string $skillName, string $source): array
{
$source = trim($source, '/');

if ($source === '' || $source === self::SKILL_SOURCE_CUSTOM) {
return ['source' => self::SKILL_SOURCE_CUSTOM];
}

if ($source === self::SKILL_SOURCE_OFFICIAL || $source === self::LEGACY_OFFICIAL_SKILLS_SOURCE) {
return ['source' => self::SKILL_SOURCE_OFFICIAL];
}

$parts = explode('/', $source);

if (count($parts) < 2 || $parts[0] === '' || $parts[1] === '') {
return ['source' => self::SKILL_SOURCE_CUSTOM];
}

$repo = $parts[0].'/'.$parts[1];
$basePath = implode('/', array_slice($parts, 2));
$path = $basePath === '' ? $skillName : $basePath.'/'.$skillName;

return $this->githubMetadata($skillName, $repo, $path);
}

/**
* @return array<string, string>
*/
protected function githubMetadata(string $skillName, mixed $repo, mixed $path = null): array
{
if (! is_string($repo) || $repo === '') {
return ['source' => self::SKILL_SOURCE_CUSTOM];
}

$metadata = [
'source' => self::SKILL_SOURCE_GITHUB,
'repo' => $repo,
];

if (is_string($path) && $path !== '' && $path !== $skillName) {
$metadata['path'] = $path;
}

return $metadata;
}

/**
* @param array<string, string> $currentMetadata
* @param array<string, string> $incomingMetadata
* @return array<string, string>
*/
protected function resolveSkillMetadata(string $skillName, array $currentMetadata, array $incomingMetadata): array
{
$currentMetadata = $this->normalizeSkillMetadata($skillName, $currentMetadata);
$incomingMetadata = $this->normalizeSkillMetadata($skillName, $incomingMetadata);

$currentSource = $currentMetadata['source'] ?? null;
$incomingSource = $incomingMetadata['source'] ?? null;

// Preserve existing GitHub metadata when the incoming entry only carries the default custom source.
if ($currentSource === self::SKILL_SOURCE_GITHUB && $incomingSource === self::SKILL_SOURCE_CUSTOM) {
return $currentMetadata;
}

return $incomingMetadata;
}

/**
* @param array<string, array<string, string>> $metadata
* @return array<string, array<string, string>>
*/
protected function sortSkillsByName(array $metadata): array
{
ksort($metadata);

return $metadata;
}

public function getMcp(): bool
{
return $this->get('mcp', false);
Expand Down Expand Up @@ -135,6 +359,13 @@ public function flush(): void
}
}

protected function getRawSkills(): array
{
$skills = $this->get('skills', []);

return is_array($skills) ? $skills : [];
}

protected function get(string $key, mixed $default = null): mixed
{
$config = $this->all();
Expand Down
Loading
Loading