Developer-Dashboard

 view release on metacpan or  search on metacpan

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


# dispatch($skill_name, $command, @args)
# Executes a command from an installed skill.
# Input: skill repo name, command name, and command arguments.
# Output: command output or error hash.
sub dispatch {
    my ( $self, $skill_name, $command, @args ) = @_;
    return { error => 'Missing skill name' } if !$skill_name;
    return { error => 'Missing command name' } if !$command;

    my $skill_path = $self->{manager}->get_skill_path( $skill_name, include_disabled => 1 );
    my $suggest = Developer::Dashboard::CLI::Suggest->new(
        paths   => $self->{manager}{paths},
        manager => $self->{manager},
    );
    return { error => $suggest->unknown_skill_command_message( $skill_name, $command ) } if !$skill_path;
    return { error => $suggest->unknown_skill_command_message( $skill_name, $command ) } if !$self->{manager}->is_enabled($skill_name);

    my $command_spec = $self->_command_spec( $skill_name, $command );
    my $cmd_path = $command_spec ? $command_spec->{cmd_path} : undef;
    my $command_skill_path = $command_spec ? $command_spec->{skill_path} : undef;
    return { error => $suggest->unknown_skill_command_message( $skill_name, $command ) } if !$cmd_path;

    my $hook_result = $self->execute_hooks( $skill_name, $command, @args );
    return $hook_result if $hook_result->{error};
    my @skill_layers = $command_spec ? @{ $command_spec->{skill_layers} || [] } : $self->_skill_layers($skill_name);

    my %env = $self->_skill_env(
        skill_name   => $skill_name,
        skill_path   => $command_skill_path || $skill_path,
        skill_layers => \@skill_layers,
        command      => $command_spec ? $command_spec->{command_name} : $command,
        result_state => $hook_result->{result_state} || {},
    );
    my @command = command_argv_for_path($cmd_path);

    my ( $stdout, $stderr, $exit ) = capture {
        local %ENV = ( %ENV, %env );
        Developer::Dashboard::Runtime::Result::set_current( $hook_result->{result_state} || {} );
        if ( ref( $hook_result->{last_result} ) eq 'HASH' && %{ $hook_result->{last_result} } ) {
            Developer::Dashboard::Runtime::Result::set_last_result( $hook_result->{last_result} );
        }
        else {
            Developer::Dashboard::Runtime::Result::clear_last_result();
        }
        Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $self->{manager}{paths} );
        Developer::Dashboard::EnvLoader->load_skill_layers( skill_layers => \@skill_layers );
        system( @command, @args );
    };
    my $hook_stdout = join '', map { defined $_->{stdout} ? $_->{stdout} : '' } values %{ $hook_result->{hooks} || {} };
    my $hook_stderr = join '', map { defined $_->{stderr} ? $_->{stderr} : '' } values %{ $hook_result->{hooks} || {} };
    return {
        stdout    => $hook_stdout . $stdout,
        stderr    => $hook_stderr . $stderr,
        exit_code => $exit,
        hooks     => $hook_result->{hooks} || {},
    };
}

# exec_command($skill_name, $command, @args)
# Executes one skill command by streaming hooks first and then replacing the
# current helper process with the resolved skill command so interactive stdin,
# stdout, and stderr behave exactly like a direct invocation.
# Input: skill repo name, command name, and command arguments.
# Output: never returns on success; otherwise returns an error hash.
sub exec_command {
    my ( $self, $skill_name, $command, @args ) = @_;
    return { error => 'Missing skill name' } if !$skill_name;
    return { error => 'Missing command name' } if !$command;

    my $skill_path = $self->{manager}->get_skill_path( $skill_name, include_disabled => 1 );
    my $suggest = Developer::Dashboard::CLI::Suggest->new(
        paths   => $self->{manager}{paths},
        manager => $self->{manager},
    );
    return { error => $suggest->unknown_skill_command_message( $skill_name, $command ) } if !$skill_path;
    return { error => $suggest->unknown_skill_command_message( $skill_name, $command ) } if !$self->{manager}->is_enabled($skill_name);

    my $command_spec = $self->_command_spec( $skill_name, $command );
    my $cmd_path = $command_spec ? $command_spec->{cmd_path} : undef;
    my $command_skill_path = $command_spec ? $command_spec->{skill_path} : undef;
    return { error => $suggest->unknown_skill_command_message( $skill_name, $command ) } if !$cmd_path;

    my @skill_layers = $command_spec ? @{ $command_spec->{skill_layers} || [] } : $self->_skill_layers($skill_name);
    my $hook_result = $self->_execute_hooks_streaming( $skill_name, $command_spec ? $command_spec->{command_name} : $command, \@skill_layers, @args );
    return $hook_result if $hook_result->{error};

    my %env = $self->_skill_env(
        skill_name   => $skill_name,
        skill_path   => $command_skill_path || $skill_path,
        skill_layers => \@skill_layers,
        command      => $command_spec ? $command_spec->{command_name} : $command,
        result_state => $hook_result->{result_state} || {},
    );
    my @command = command_argv_for_path($cmd_path);
    %ENV = ( %ENV, %env );
    Developer::Dashboard::Runtime::Result::set_current( $hook_result->{result_state} || {} );
    if ( ref( $hook_result->{last_result} ) eq 'HASH' && %{ $hook_result->{last_result} } ) {
        Developer::Dashboard::Runtime::Result::set_last_result( $hook_result->{last_result} );
    }
    else {
        Developer::Dashboard::Runtime::Result::clear_last_result();
    }
    Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $self->{manager}{paths} );
    Developer::Dashboard::EnvLoader->load_skill_layers( skill_layers => \@skill_layers );
    return $self->_exec_resolved_command( $cmd_path, \@command, \@args );
}

# execute_hooks($skill_name, $command, @args)
# Executes hook files from skill's cli/<command>.d/ directory before main command.
# Input: skill repo name, command name, and command arguments.
# Output: hash with hook results and environment.
sub execute_hooks {
    my ( $self, $skill_name, $command, @args ) = @_;
    return { hooks => {}, result_state => {} } if !$skill_name || !$command;
    my $skill_path = $self->{manager}->get_skill_path( $skill_name, include_disabled => 1 );
    return { hooks => {}, result_state => {} } if !$skill_path;
    return { hooks => {}, result_state => {} } if !$self->{manager}->is_enabled($skill_name);
    my $command_spec = $self->_command_spec( $skill_name, $command );
    my @skill_layers = $command_spec ? @{ $command_spec->{skill_layers} || [] } : $self->_skill_layers($skill_name);
    return { hooks => {}, result_state => {} } if !@skill_layers;
    my $resolved_command = $command_spec ? $command_spec->{command_name} : $command;

    my %results;
    my $last_result = {};
    for my $layer_path (@skill_layers) {
        my $hooks_dir = File::Spec->catdir( $layer_path, 'cli', "$resolved_command.d" );
        next if !-d $hooks_dir;
        opendir( my $dh, $hooks_dir ) or die "Unable to read $hooks_dir: $!";
        for my $entry ( sort grep { $_ ne '.' && $_ ne '..' } readdir($dh) ) {
            my $hook_path = File::Spec->catfile( $hooks_dir, $entry );
            next unless is_runnable_file($hook_path);

            my %env = $self->_skill_env(
                skill_name   => $skill_name,
                skill_path   => $layer_path,
                skill_layers => \@skill_layers,
                command      => $resolved_command,
                result_state => \%results,
            );
            my @command = command_argv_for_path($hook_path);
            my ( $stdout, $stderr, $exit ) = capture {
                local %ENV = ( %ENV, %env );
                Developer::Dashboard::Runtime::Result::set_current( \%results );
                if (%{$last_result}) {
                    Developer::Dashboard::Runtime::Result::set_last_result($last_result);
                }
                else {
                    Developer::Dashboard::Runtime::Result::clear_last_result();
                }
                Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $self->{manager}{paths} );
                Developer::Dashboard::EnvLoader->load_skill_layers( skill_layers => \@skill_layers );
                system( @command, @args );
            };
            my $result_key = $entry;
            if ( exists $results{$entry} ) {
                my $leaf = basename( dirname($hook_path) );
                $result_key = $leaf . '/' . basename($hook_path);
            }
            $results{$result_key} = {
                stdout    => $stdout,
                stderr    => $stderr,
                exit_code => $exit,
            };
            $last_result = {
                file   => $hook_path,
                exit   => $exit,
                STDOUT => $stdout,
                STDERR => $stderr,
            };
        }
        closedir($dh);
    }
    my %payload = (
        hooks        => \%results,
        result_state => \%results,
    );
    $payload{last_result} = $last_result if %{$last_result};
    return \%payload;
}

# _execute_hooks_streaming($skill_name, $command, $skill_layers, @args)
# Executes skill hook files while preserving live stdio so interactive hooks
# and later main commands can still read from the caller's stdin and print
# prompts without buffering surprises.
# Input: skill repo name, resolved command name, array reference of skill layer
# paths, and command arguments.
# Output: hash reference containing hook captures, result_state, and
# last_result.
sub _execute_hooks_streaming {
    my ( $self, $skill_name, $command, $skill_layers, @args ) = @_;
    return { hooks => {}, result_state => {} } if !$skill_name || !$command;
    my @skill_layers = @{ $self->_arrayref_or_empty($skill_layers) };
    return { hooks => {}, result_state => {} } if !@skill_layers;

    my %results;
    my $last_result = {};
    for my $layer_path (@skill_layers) {
        my $hooks_dir = File::Spec->catdir( $layer_path, 'cli', "$command.d" );
        next if !-d $hooks_dir;
        opendir( my $dh, $hooks_dir ) or die "Unable to read $hooks_dir: $!";
        for my $entry ( sort grep { $_ ne '.' && $_ ne '..' } readdir($dh) ) {
            my $hook_path = File::Spec->catfile( $hooks_dir, $entry );
            next unless is_runnable_file($hook_path);

            my %env = $self->_skill_env(
                skill_name   => $skill_name,
                skill_path   => $layer_path,
                skill_layers => \@skill_layers,
                command      => $command,
                result_state => \%results,
            );
            my @hook_command = command_argv_for_path($hook_path);
            my $run = $self->_run_child_command_streaming(
                command      => \@hook_command,
                args         => \@args,
                env          => \%env,
                skill_layers => \@skill_layers,
                result_state => \%results,
                last_result  => $last_result,
                stdin_mode   => 'null',
            );
            my $result_key = $entry;
            if ( exists $results{$entry} ) {
                my $leaf = basename( dirname($hook_path) );
                $result_key = $leaf . '/' . basename($hook_path);
            }
            $results{$result_key} = {
                stdout    => $run->{stdout},
                stderr    => $run->{stderr},
                exit_code => $run->{exit_code},
            };
            $last_result = {
                file   => $hook_path,
                exit   => $run->{exit_code},
                STDOUT => $run->{stdout},
                STDERR => $run->{stderr},
            };
        }
        closedir($dh);
    }

    my %payload = (
        hooks        => \%results,
        result_state => \%results,
    );
    $payload{last_result} = $last_result if %{$last_result};
    return \%payload;
}

# _run_child_command_streaming(%args)
# Launches one child command with inherited stdin, streams stdout and stderr
# live, and still captures both streams for RESULT-aware callers.
# Input: hash containing command array ref, args array ref, env hash ref,
# skill_layers array ref, result_state hash ref, optional last_result hash
# ref, and optional stdin_mode string.
# Output: hash reference containing stdout, stderr, and exit_code.
sub _run_child_command_streaming {
    my ( $self, %args ) = @_;
    my @command = @{ $self->_arrayref_or_empty( $args{command} ) };
    my @argv = @{ $self->_arrayref_or_empty( $args{args} ) };
    my %env = %{ $self->_hashref_or_empty( $args{env} ) };
    my @skill_layers = @{ $self->_arrayref_or_empty( $args{skill_layers} ) };
    my $result_state = $self->_hashref_or_empty( $args{result_state} );
    my $last_result = $args{last_result};
    my $stdin_mode = $self->_defined_or_default( $args{stdin_mode}, 'inherit' );
    my $stdin_spec = '<&STDIN';
    my $stdin_fh;
    if ( $stdin_mode eq 'null' ) {
        open $stdin_fh, '<', File::Spec->devnull() or die "Unable to open " . File::Spec->devnull() . " for streaming skill hook stdin: $!";
        $stdin_spec = '<&' . fileno($stdin_fh);
    }
    my $stderr = gensym();
    my $stdout;
    my ( $stdout_text, $stderr_text ) = ( '', '' );
    my $pid;
    {
        local %ENV = ( %ENV, %env );
        Developer::Dashboard::Runtime::Result::set_current($result_state);
        if ( ref($last_result) eq 'HASH' && %{$last_result} ) {
            Developer::Dashboard::Runtime::Result::set_last_result($last_result);
        }
        else {
            Developer::Dashboard::Runtime::Result::clear_last_result();
        }
        Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $self->{manager}{paths} );
        Developer::Dashboard::EnvLoader->load_skill_layers( skill_layers => \@skill_layers );
        $pid = open3( $stdin_spec, $stdout, $stderr, @command, @argv );
    }
    close $stdin_fh if $stdin_fh;

    my $selector  = IO::Select->new( $stdout, $stderr );
    my $stdout_fd = fileno($stdout);
    my $stderr_fd = fileno($stderr);
    local $| = 1;
    STDOUT->autoflush(1);
    STDERR->autoflush(1);

    while ( my @ready = $selector->can_read ) {
        for my $fh (@ready) {
            my $buffer = '';
            my $read = sysread( $fh, $buffer, 8192 );
            if ( !defined $read || $read == 0 ) {
                $selector->remove($fh);
                close $fh;
                next;
            }

            if ( fileno($fh) == $stdout_fd ) {
                print STDOUT $buffer;
                $stdout_text .= $buffer;
                next;
            }

            if ( fileno($fh) == $stderr_fd ) {
                print STDERR $buffer;
                $stderr_text .= $buffer;
                next;
            }
        }
    }

    waitpid( $pid, 0 );
    return {
        stdout    => $stdout_text,
        stderr    => $stderr_text,
        exit_code => $? >> 8,
    };
}



( run in 0.510 second using v1.01-cache-2.11-cpan-39bf76dae61 )