Developer-Dashboard

 view release on metacpan or  search on metacpan

lib/Developer/Dashboard/SkillManager.pm  view on Meta::CPAN

        return "$label (error: $error)";
    }
    return "$label from $path" if -f $path;
    return $label;
}

# _progress_error_text($error)
# Compacts one install error into a single visible task-board line.
# Input: raw error string from one dependency or source install step.
# Output: single-line error summary string.
sub _progress_error_text {
    my ( $self, $error ) = @_;
    return 'unknown failure' if !defined $error || $error eq '';
    $error =~ s/\s+/ /g;
    $error =~ s/\A\s+//;
    $error =~ s/\s+\z//;
    return $error;
}

# _progress_emit($event)
# Sends one skill-install progress event to the optional progress callback.
# Input: event hash reference.
# Output: true value.
sub _progress_emit {
    my ( $self, $event ) = @_;
    my $progress = $self->{progress};
    return 1 if !$progress || ref($progress) ne 'CODE';
    $progress->($event);
    return 1;
}

# _active_dependency_task_id()
# Returns the dependency-task id currently being executed so lower-level
# helpers can stream detail lines into the visible progress board.
# Input: none.
# Output: task id string or undef.
sub _active_dependency_task_id {
    my ($self) = @_;
    return $self->{_active_dependency_task_id};
}

# _progress_detail_line($line, %args)
# Streams one compact detail line into the active dependency task.
# Input: raw text line plus optional explicit task_id.
# Output: true value.
sub _progress_detail_line {
    my ( $self, $line, %args ) = @_;
    my $task_id = $args{task_id} || $self->_active_dependency_task_id || return 1;
    return 1 if !defined $line;
    $line =~ s/\r//g;
    $line =~ s/\s+\z//;
    return 1 if $line eq '';
    return $self->_progress_emit(
        {
            task_id     => $task_id,
            detail_line => $line,
        }
    );
}

# _run_streaming_command(%args)
# Runs one external dependency command while streaming a rolling output
# snapshot into the active progress task and collecting the full transcript.
# Input: command array reference plus optional cwd and banner line.
# Output: hash reference with stdout, stderr, and exit fields.
sub _run_streaming_command {
    my ( $self, %args ) = @_;
    my $command = $args{command} || die "Missing command for streaming execution\n";
    die "Streaming command must be an array reference\n" if ref($command) ne 'ARRAY' || !@{$command};
    my $cwd = $args{cwd};
    my $banner = $args{banner};
    my $env = $args{env};
    die "Streaming command env must be a hash reference\n" if defined $env && ref($env) ne 'HASH';
    $self->_progress_detail_line($banner) if defined $banner && $banner ne '';

    my $stdout_handle;
    my $stderr_handle = gensym;
    my $stdin_handle  = gensym;
    my $pid;
    my $stdout = '';
    my $stderr = '';
    my %target_for;

    my $launcher = sub {
        local @ENV{ keys %{$env} } = values %{$env} if $env;
        $pid = open3( $stdin_handle, $stdout_handle, $stderr_handle, @{$command} );
    };

    if ( defined $cwd && $cwd ne '' ) {
        my $orig = Cwd::getcwd();
        chdir $cwd or die "Unable to chdir to $cwd for command launch: $!";
        my $ok = eval { $launcher->(); 1 };
        my $error = $@;
        chdir $orig or die "Unable to chdir back to $orig after command launch: $!";
        die $error if !$ok;
    }
    else {
        $launcher->();
    }

    close $stdin_handle if $stdin_handle;
    %target_for = (
        fileno($stdout_handle) => \$stdout,
        fileno($stderr_handle) => \$stderr,
    );

    my $selector = IO::Select->new();
    $selector->add($stdout_handle) if $stdout_handle;
    $selector->add($stderr_handle) if $stderr_handle;
    while ( my @ready = $selector->can_read ) {
        for my $handle (@ready) {
            my $chunk = '';
            my $read = sysread( $handle, $chunk, 8192 );
            if ( !defined $read || $read == 0 ) {
                $selector->remove($handle);
                close $handle;
                next;
            }
            my $slot = $target_for{ fileno($handle) };
            ${$slot} .= $chunk if $slot;
            for my $line ( split /\n/, $chunk ) {
                $self->_progress_detail_line($line);
            }
        }
    }

    waitpid $pid, 0;
    return {

lib/Developer/Dashboard/SkillManager.pm  view on Meta::CPAN

                chdir $skills_root or die "Unable to chdir to $skills_root for $manifest_name dependency install: $!";
                ( $stdout, $stderr, $status ) = capture {
                    system( 'dashboard', 'skills', 'install', $dependency );
                };
                chdir $cwd or die "Unable to chdir back to $cwd after $manifest_name dependency install: $!";
                1;
            };
            my $eval_error = $@;
            if ($eval_error) {
                chdir $cwd if Cwd::getcwd() ne $cwd;
                die $eval_error;
            }
            ( $stdout, $stderr, $status );
        };
        return {
            error => "Failed to install dependent skills for $skill_path via $manifest_name: $step_stderr",
        } if $exit != 0;
        push @stdout, $step_stdout if defined $step_stdout && $step_stdout ne '';
        push @stderr, $step_stderr if defined $step_stderr && $step_stderr ne '';
    }

    return { success => 1, skipped => 1 } if !@stdout && !@stderr;
    return {
        success => 1,
        stdout  => join( '', @stdout ),
        stderr  => join( '', @stderr ),
    };
}

# _install_skill_package_json($skill_path)
# Installs Node dependencies declared by package.json into the dashboard home
# node_modules directory by running npx-wrapped npm inside a private staging
# workspace and then merging the resulting dependency tree into HOME/node_modules.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_package_json {
    my ( $self, $skill_path ) = @_;
    my $package_json = File::Spec->catfile( $skill_path, 'package.json' );
    return { success => 1, skipped => 1 } if !-f $package_json;
    my @specs = $self->_package_json_dependency_specs($package_json);
    return { success => 1, skipped => 1 } if !@specs;

    die "Unable to chdir to " . $self->{paths}->home . " for package.json dependency install: No such file or directory\n"
      if !-d $self->{paths}->home;
    my $workspace_parent = File::Spec->catdir( $self->{paths}->home_runtime_root, 'cache', 'node-package-installs' );
    my $target_root = File::Spec->catdir( $self->{paths}->home, 'node_modules' );
    make_path($workspace_parent) if !-d $workspace_parent;
    make_path($target_root) if !-d $target_root;
    my $workspace = tempdir( 'npm-install-XXXXXX', DIR => $workspace_parent, CLEANUP => 1 );
    my $workspace_package_json = File::Spec->catfile( $workspace, 'package.json' );
    open my $workspace_fh, '>', $workspace_package_json or die "Unable to write $workspace_package_json: $!";
    print {$workspace_fh} encode_json(
        {
            name    => 'developer-dashboard-skill-runtime',
            version => '1.0.0',
            private => JSON::XS::true(),
        }
    );
    close $workspace_fh;

    my $run = $self->_run_streaming_command(
        command => [ 'npx', '--yes', 'npm', 'install', @specs ],
        cwd     => $workspace,
        banner  => "Installing Node dependencies for " . basename($skill_path) . " from $package_json: " . join( ' ', @specs ),
    );
    my ( $npm_stdout, $npm_stderr, $npm_exit ) = @{$run}{qw(stdout stderr exit)};
    return {
        error => "Failed to install skill Node dependencies for $skill_path: $npm_stderr",
    } if $npm_exit != 0;

    my $workspace_modules = File::Spec->catdir( $workspace, 'node_modules' );
    my ( $copy_stdout, $copy_stderr, $copy_exit ) = ( '', '', 0 );
    if ( -d $workspace_modules ) {
        my $copy_error = eval {
            $self->_copy_tree_contents( $workspace_modules, $target_root );
            1;
        } ? '' : "$@";
        $copy_error =~ s/\s+\z// if defined $copy_error;
        return {
            error => "Failed to merge skill Node dependencies into $target_root for $skill_path: $copy_error",
        } if $copy_error ne '';
    }

    return {
        success => 1,
        stdout  => $npm_stdout . $copy_stdout,
        stderr  => $npm_stderr . $copy_stderr,
    };
}

# _install_skill_requirements_txt($skill_path)
# Installs Python dependencies declared by requirements.txt into the current
# user's Python environment through python -m pip --user.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_requirements_txt {
    my ( $self, $skill_path ) = @_;
    my $requirements = File::Spec->catfile( $skill_path, 'requirements.txt' );
    return { success => 1, skipped => 1 } if !-f $requirements;

    my $run = $self->_run_streaming_command(
        command => [ $self->_python_dependency_command, '-m', 'pip', 'install', '--user', '--requirement', $requirements ],
        cwd     => $skill_path,
        banner  => "Installing Python dependencies for " . basename($skill_path) . " from $requirements",
    );
    my ( $stdout, $stderr, $exit ) = @{$run}{qw(stdout stderr exit)};
    return {
        error => "Failed to install skill Python dependencies for $skill_path: $stderr",
    } if $exit != 0;

    return {
        success => 1,
        stdout  => $stdout,
        stderr  => $stderr,
    };
}

# _python_dependency_command()
# Resolves the preferred Python executable for requirements.txt installs.
# Input: none.
# Output: executable path or command name string.
sub _python_dependency_command {
    return command_in_path('python') || command_in_path('python3') || 'python';
}

# _package_json_dependency_specs($package_json)
# Extracts one deterministic dependency install list from package.json without
# asking npm to treat the skill checkout itself as an installable package.
# Input: absolute package.json path.
# Output: ordered list of npm install spec strings.
sub _package_json_dependency_specs {
    my ( $self, $package_json ) = @_;
    return () if !defined $package_json || !-f $package_json;

    open my $fh, '<', $package_json or die "Unable to read $package_json: $!";
    local $/;
    my $content = <$fh>;
    close $fh;

    my $decoded = eval { decode_json($content) };
    die "Unable to parse $package_json: $@" if !$decoded || $@;

    my @specs;
    for my $section ( qw(dependencies devDependencies optionalDependencies peerDependencies) ) {
        my $entries = $decoded->{$section};
        next if ref($entries) ne 'HASH';
        for my $name ( sort keys %{$entries} ) {
            my $version = $entries->{$name};
            push @specs, defined $version && $version ne '' ? "$name\@$version" : $name;
        }
    }

    return @specs;
}

# _copy_tree_contents($source_root, $target_root)
# Recursively copies the contents of one directory tree into another without
# relying on external shell utilities.
# Input: absolute source directory path and absolute target directory path.
# Output: true value after the copy succeeds, or dies on the first copy error.
sub _copy_tree_contents {

lib/Developer/Dashboard/SkillManager.pm  view on Meta::CPAN

        $self->_progress_emit( { task_id => $task_id, status => 'running', label => "Install/update $source" } )
          if $args{progress};
        my $result = $self->_install_to_skills_root( $source, $skills_root );
        if ( $result->{error} ) {
            $self->_progress_emit( { task_id => $task_id, status => 'failed', label => "Install/update $source" } )
              if $args{progress};
            return $result;
        }
        push @{$operations}, {
            manifest       => $manifest_name,
            source         => $source,
            repo_name      => $result->{repo_name},
            path           => $result->{path},
            version_before => $result->{version_before},
            version_after  => $result->{version_after},
            status         => $result->{status},
            changed        => $result->{changed},
        } if ref($operations) eq 'ARRAY';
        $self->_progress_emit( { task_id => $task_id, status => 'done', label => $self->_install_result_progress_label($source, $result) } )
          if $args{progress};
        $idx++;
    }

    return { success => 1 };
}

# _install_result_progress_label($source, $result)
# Builds a concise source-level completion label for an install result.
# Input: source string and install result hash reference.
# Output: operator-facing progress label with status and version transition.
sub _install_result_progress_label {
    my ( $self, $source, $result ) = @_;
    my $repo = ref($result) eq 'HASH' && $result->{repo_name} ? $result->{repo_name} : $source;
    my $status = ref($result) eq 'HASH' && $result->{status} ? $result->{status} : 'done';
    my $before = ref($result) eq 'HASH' && defined $result->{version_before} ? $result->{version_before} : '-';
    my $after  = ref($result) eq 'HASH' && defined $result->{version_after}  ? $result->{version_after}  : '-';
    return "$repo $status ($before -> $after)";
}

# _install_skill_aptfile($skill_path)
# Installs aptfile packages on Debian-like hosts after printing the requested
# package list.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_aptfile {
    my ( $self, $skill_path ) = @_;
    my @apt_packages = $self->_skill_apt_packages($skill_path);
    return { success => 1, skipped => 1 } if !@apt_packages || !$self->_is_debian_like;
    my @missing_packages = $self->_packages_missing(
        sub { $self->_apt_package_is_installed( $_[0] ) },
        @apt_packages
    );
    return {
        success     => 1,
        skipped     => 1,
        skip_reason => 'all aptfile packages already installed',
    } if !@missing_packages;

    my $aptfile = File::Spec->catfile( $skill_path, 'aptfile' );
    my @runner_prefix = $self->_skill_package_runner_prefix;
    my $run = $self->_run_streaming_command(
        command => [ @runner_prefix, 'apt-get', 'install', '-y', @missing_packages ],
        banner  => "Installing apt packages for " . basename($skill_path) . " from $aptfile: " . join( ' ', @missing_packages ),
    );
    return {
        error => "Failed to install skill apt dependencies for $skill_path: $run->{stderr}",
    } if $run->{exit} != 0;

    return {
        success => 1,
        stdout  => $run->{stdout},
        stderr  => $run->{stderr},
    };
}

# _install_skill_apkfile($skill_path)
# Installs apkfile packages on Alpine hosts after printing the requested
# package list.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_apkfile {
    my ( $self, $skill_path ) = @_;
    my $apkfile = File::Spec->catfile( $skill_path, 'apkfile' );
    my @packages = $self->_dependency_file_lines($apkfile);
    return { success => 1, skipped => 1 } if !@packages || !$self->_is_alpine;
    my @missing_packages = $self->_packages_missing(
        sub { $self->_apk_package_is_installed( $_[0] ) },
        @packages
    );
    return {
        success     => 1,
        skipped     => 1,
        skip_reason => 'all apkfile packages already installed',
    } if !@missing_packages;

    my @runner_prefix = $self->_skill_package_runner_prefix;
    my $run = $self->_run_streaming_command(
        command => [ @runner_prefix, 'apk', 'add', '--no-cache', @missing_packages ],
        banner  => "Installing apk packages for " . basename($skill_path) . " from $apkfile: " . join( ' ', @missing_packages ),
    );
    return {
        error => "Failed to install skill apk dependencies for $skill_path: $run->{stderr}",
    } if $run->{exit} != 0;

    return {
        success => 1,
        stdout  => $run->{stdout},
        stderr  => $run->{stderr},
    };
}

# _install_skill_dnfile($skill_path)
# Installs dnfile packages on Fedora hosts after printing the requested
# package list.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_dnfile {
    my ( $self, $skill_path ) = @_;
    my $dnfile = File::Spec->catfile( $skill_path, 'dnfile' );
    my @packages = $self->_dependency_file_lines($dnfile);
    return { success => 1, skipped => 1 } if !@packages || !$self->_is_fedora;
    my @missing_packages = $self->_packages_missing(
        sub { $self->_dnf_package_is_installed( $_[0] ) },
        @packages
    );
    return {
        success     => 1,
        skipped     => 1,
        skip_reason => 'all dnfile packages already installed',
    } if !@missing_packages;

    my @runner_prefix = $self->_skill_package_runner_prefix;
    my $run = $self->_run_streaming_command(
        command => [ @runner_prefix, 'dnf', 'install', '-y', @missing_packages ],
        banner  => "Installing dnf packages for " . basename($skill_path) . " from $dnfile: " . join( ' ', @missing_packages ),
    );
    return {
        error => "Failed to install skill dnf dependencies for $skill_path: $run->{stderr}",
    } if $run->{exit} != 0;

    return {
        success => 1,
        stdout  => $run->{stdout},
        stderr  => $run->{stderr},
    };
}

# _install_skill_wingetfile($skill_path)
# Installs wingetfile packages on Windows hosts after printing the requested
# package list.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_wingetfile {
    my ( $self, $skill_path ) = @_;
    my $wingetfile = File::Spec->catfile( $skill_path, 'wingetfile' );
    my @packages = $self->_dependency_file_lines($wingetfile);
    return { success => 1, skipped => 1 } if !@packages || !$self->_is_windows;

    my @stdout;
    my @stderr;
    for my $package (@packages) {
        my $run = $self->_run_streaming_command(
            command => [
                'winget', 'install',
                '--id', $package,
                '--exact',
                '--accept-package-agreements',
                '--accept-source-agreements',
                '--disable-interactivity',
            ],
            banner => "Installing winget packages for " . basename($skill_path) . " from $wingetfile: $package",
        );
        return {
            error => "Failed to install skill winget dependencies for $skill_path: $run->{stderr}",
        } if $run->{exit} != 0;
        push @stdout, $run->{stdout} if defined $run->{stdout} && $run->{stdout} ne '';
        push @stderr, $run->{stderr} if defined $run->{stderr} && $run->{stderr} ne '';
    }

    return {
        success => 1,
        stdout  => join( '', @stdout ),
        stderr  => join( '', @stderr ),
    };
}

# _skill_package_runner_prefix()
# Returns the command prefix used for privileged package-manager installs.
# Input: none.
# Output: list containing 'sudo' for non-root users, or an empty list for root.
sub _skill_package_runner_prefix {
    my ($self) = @_;
    return () if ( $> || 0 ) == 0;
    return ('sudo');
}

# _install_skill_brewfile($skill_path)
# Installs brewfile packages on macOS after printing the requested package list.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_brewfile {
    my ( $self, $skill_path ) = @_;
    my $brewfile = File::Spec->catfile( $skill_path, 'brewfile' );
    my @packages = $self->_dependency_file_lines($brewfile);
    return { success => 1, skipped => 1 } if !@packages || $self->_current_os ne 'darwin';

    my $run = $self->_run_streaming_command(
        command => [ 'brew', 'install', @packages ],
        banner  => "Installing brew packages for " . basename($skill_path) . " from $brewfile: " . join( ' ', @packages ),
    );
    return {
        error => "Failed to install skill brew dependencies for $skill_path: $run->{stderr}",
    } if $run->{exit} != 0;

    return {
        success => 1,
        stdout  => $run->{stdout},
        stderr  => $run->{stderr},
    };
}

# _install_skill_cpanfile($skill_path)
# Installs shared Perl dependencies from cpanfile into HOME perl5.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_cpanfile {
    my ( $self, $skill_path ) = @_;
    my $cpanfile = File::Spec->catfile( $skill_path, 'cpanfile' );
    return { success => 1, skipped => 1 } if !-f $cpanfile;
    my $shared_root = $self->_ensure_perl_root( $self->_shared_perl_root );
    my $run = $self->_run_streaming_command(
        command => [ 'cpanm', '--notest', '-L', $shared_root, '--cpanfile', $cpanfile, '--installdeps', '.' ],
        cwd     => $skill_path,
        env     => $self->_skill_cpanm_env,
        banner  => "Installing Perl dependencies for " . basename($skill_path) . " from $cpanfile",
    );
    my ( $stdout, $stderr, $exit ) = @{$run}{qw(stdout stderr exit)};
    return {
        error => "Failed to install skill dependencies for $skill_path: $stderr",
    } if $exit != 0;

    return {
        success => 1,
        stdout  => $stdout,
        stderr  => $stderr,
    };
}

# _install_skill_cpanfile_local($skill_path)
# Installs skill-local Perl dependencies from cpanfile.local into ./perl5.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_cpanfile_local {
    my ( $self, $skill_path ) = @_;
    my $cpanfile_local = File::Spec->catfile( $skill_path, 'cpanfile.local' );
    return { success => 1, skipped => 1 } if !-f $cpanfile_local;
    my $local_root = $self->_ensure_perl_root( $self->_skill_local_perl_root($skill_path) );
    my $run = $self->_run_streaming_command(
        command => [ 'cpanm', '--notest', '-L', $local_root, '--cpanfile', $cpanfile_local, '--installdeps', '.' ],
        cwd     => $skill_path,
        env     => $self->_skill_cpanm_env,
        banner  => "Installing local Perl dependencies for " . basename($skill_path) . " from $cpanfile_local",
    );
    my ( $stdout, $stderr, $exit ) = @{$run}{qw(stdout stderr exit)};
    return {
        error => "Failed to install skill local dependencies for $skill_path: $stderr",
    } if $exit != 0;

    return {
        success => 1,
        stdout  => $stdout,
        stderr  => $stderr,
    };
}

# _skill_cpanm_env()
# Returns the non-interactive CPAN environment required for skill dependency installs.
# Input: none.
# Output: hash reference of environment variables for cpanm child processes.
sub _skill_cpanm_env {
    my ($self) = @_;
    return {
        PERL_MM_USE_DEFAULT           => 1,
        NONINTERACTIVE_TESTING        => 1,
        PERL_CANARY_STABILITY_NOPROMPT => 1,
    };
}

# _install_skill_makefile($skill_path)
# Runs the optional skill Makefile command chain before ddfile processing.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_makefile {
    my ( $self, $skill_path ) = @_;
    my $makefile = File::Spec->catfile( $skill_path, 'Makefile' );
    return { success => 1, skipped => 1 } if !-f $makefile;
    my $make = command_in_path('make') || 'make';

    my %targets = map { $_ => 1 } $self->_makefile_targets($makefile);
    my @commands = (
        [],
        ( $self->{skip_tests}
            ? ()
            : $targets{test}
            ? ( ['test'] )
            : $targets{tests}
            ? ( ['tests'] )
            : () ),
        ['install'],
        ( $targets{clean} ? ( ['clean'] ) : () ),
    );

    my ( @stdout, @stderr );
    my $result = eval {
        for my $args (@commands) {
            my $target_name = @{$args} ? join( ' ', @{$args} ) : 'default';
            my $run = $self->_run_streaming_command(
                command => [ $make, @{$args} ],
                cwd     => $skill_path,
                banner  => "Running make $target_name for " . basename($skill_path) . " from $makefile",
            );
            my ( $stdout, $stderr, $exit ) = @{$run}{qw(stdout stderr exit)};
            push @stdout, $stdout if defined $stdout && $stdout ne '';
            push @stderr, $stderr if defined $stderr && $stderr ne '';
            if ( $exit != 0 ) {
                my $target = 'default';
                if (@{$args}) {
                    $target = join( ' ', @{$args} );
                }
                my $detail = $stderr ne '' ? $stderr : $stdout;
                die "Failed to run skill Makefile target '$target' for $skill_path: $detail";
            }
        }
        return {
            success => 1,
            stdout  => join( '', @stdout ),
            stderr  => join( '', @stderr ),
        };
    };
    my $error = $@;
    return { error => $error } if !$result;
    return $result;
}


# _install_skill_dockerfile($skill_path)
# Builds Docker images declared by dockerfile in the skill root.
# Input: absolute skill root directory path.
# Output: result hash reference with success or error state.
sub _install_skill_dockerfile {
    my ( $self, $skill_path ) = @_;
    my $dockerfile = File::Spec->catfile( $skill_path, 'dockerfile' );
    return { success => 1, skipped => 1 } if !-f $dockerfile;

    my $run = $self->_run_streaming_command(
        command => [ 'docker', 'build', '-t', lc(basename($skill_path)), '-f', $dockerfile, $skill_path ],
        cwd     => $skill_path,
        banner  => "Building Docker image for " . basename($skill_path) . " from $dockerfile",
    );
    return {
        error => "Failed to build Docker image for $skill_path: $run->{stderr}",
    } if $run->{exit} != 0;

    return {
        success => 1,
        stdout  => $run->{stdout},
        stderr  => $run->{stderr},
    };
}

# _makefile_targets($makefile)
# Extracts simple top-level target names from one skill Makefile.
# Input: absolute Makefile path.
# Output: list of target name strings.
sub _makefile_targets {
    my ( $self, $makefile ) = @_;
    return () if !defined $makefile || !-f $makefile;
    open my $fh, '<', $makefile or die "Unable to read $makefile: $!";
    my %seen;
    my @targets;
    while ( my $line = <$fh> ) {
        next if $line =~ /^\s/;
        next if $line =~ /^\#/;
        next if $line =~ /^\./;
        next if $line !~ /^([^:=]+)\s*:(?![=])/;
        for my $target ( split /\s+/, $1 ) {
            next if !defined $target || $target eq '';
            next if $seen{$target}++;
            push @targets, $target;
        }
    }
    close $fh or die "Unable to close $makefile: $!";
    return @targets;
}

# _skill_metadata($repo_name, $skill_path)
# Summarizes the isolated filesystem and command surface for one installed skill.
# Input: repo name string and absolute skill root directory path.
# Output: metadata hash reference.
sub _skill_metadata {
    my ( $self, $repo_name, $skill_path ) = @_;
    my $cli_root = File::Spec->catdir( $skill_path, 'cli' );
    my @commands = map { $_->{name} } @{ $self->_cli_command_details($skill_path) };

    my $docker_root = File::Spec->catdir( $skill_path, 'config', 'docker' );
    my @docker_services = map { $_->{name} } @{ $self->_docker_service_details($skill_path) };
    my $pages = $self->_page_details($skill_path);
    my $collectors = $self->_collector_details( $repo_name, $skill_path );
    my $indicators_count = 0;
    for my $collector ( @{$collectors} ) {
        $indicators_count++ if $collector->{has_indicator};
    }
    my $enabled = $self->_skill_disabled($skill_path) ? JSON::XS::false() : JSON::XS::true();
    my $has_ddfile = -f File::Spec->catfile( $skill_path, 'ddfile' ) ? JSON::XS::true() : JSON::XS::false();
    my $has_aptfile = -f File::Spec->catfile( $skill_path, 'aptfile' ) ? JSON::XS::true() : JSON::XS::false();



( run in 0.713 second using v1.01-cache-2.11-cpan-995e09ba956 )