Last active
December 17, 2024 05:46
-
-
Save Ovid/ca6e73093ad281c93ca338b9cdb7b3c4 to your computer and use it in GitHub Desktop.
howdoi - a Perl script that uses OpenAI to find answers to your command line questions
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env perl | |
# vim: ft=perl | |
use v5.16.0; | |
use strict; | |
use warnings; | |
use Carp qw(croak); | |
use Data::Printer; | |
use HTML::FormatMarkdown; | |
use HTML::TreeBuilder; | |
use OpenAPI::Client::OpenAI; | |
use Path::Tiny; | |
use Term::Size; | |
use Text::Markdown qw(markdown); | |
use Pod::Usage; | |
use Getopt::Long; | |
GetOptions( | |
'help|?' => sub { pod2usage( -verbose => 1 ) }, | |
'no-color|nocolor' => \my $nocolor, | |
) or die pod2usage( -verbose => 1 ); | |
package HTML::FormatMarkdown::Colored; | |
use parent 'HTML::Formatter'; | |
# ANSI escape sequences | |
use constant { | |
RESET => "\033[0m", | |
BOLD => "\033[1m", | |
BLUE => "\033[34m", | |
CYAN => "\033[36m", | |
GREEN => "\033[32m", | |
YELLOW => "\033[33m", | |
MAGENTA => "\033[35m", | |
}; | |
sub default_values { | |
( shift->SUPER::default_values(), | |
lm => 0, | |
rm => 70, | |
); | |
} | |
sub configure { | |
my ( $self, $hash ) = @_; | |
my $lm = $self->{lm}; | |
my $rm = $self->{rm}; | |
$lm = delete $hash->{lm} if exists $hash->{lm}; | |
$lm = delete $hash->{leftmargin} if exists $hash->{leftmargin}; | |
$rm = delete $hash->{rm} if exists $hash->{rm}; | |
$rm = delete $hash->{rightmargin} if exists $hash->{rightmargin}; | |
my $width = $rm - $lm; | |
if ( $width < 1 ) { | |
warn "Bad margins, ignored" if $^W; | |
return; | |
} | |
if ( $width < 20 ) { | |
warn "Page probably too narrow" if $^W; | |
} | |
for ( keys %$hash ) { | |
warn "Unknown configure option '$_'" if $^W; | |
} | |
$self->{lm} = $lm; | |
$self->{rm} = $rm; | |
$self; | |
} | |
sub begin { | |
my $self = shift; | |
$self->SUPER::begin(); | |
$self->{maxpos} = 0; | |
$self->{curpos} = 0; | |
} | |
sub end { | |
my $self = shift; | |
$self->collect( RESET . "\n" ); | |
} | |
sub header_start { | |
my ( $self, $level ) = @_; | |
$self->vspace(1); | |
$self->out( BLUE . BOLD . '#' x $level . ' ' . RESET ); | |
1; | |
} | |
sub header_end { | |
my ( $self, $level ) = @_; | |
$self->out( BLUE . BOLD . ' ' . '#' x $level . RESET ); | |
$self->vspace(1); | |
} | |
sub bullet { | |
my $self = shift; | |
$self->SUPER::bullet( CYAN . $_[0] . ' ' . RESET ); | |
} | |
sub hr_start { | |
my $self = shift; | |
$self->vspace(1); | |
$self->out( YELLOW . '- - -' . RESET ); | |
$self->vspace(1); | |
} | |
sub img_start { | |
my ( $self, $node ) = @_; | |
my $alt = $node->attr('alt') || ''; | |
my $src = $node->attr('src'); | |
$self->out( MAGENTA . "" . RESET ); | |
} | |
sub a_start { | |
my ( $self, $node ) = @_; | |
if ( $node->attr('name') ) { | |
1; | |
} | |
elsif ( $node->attr('href') =~ /^#/ ) { | |
1; | |
} | |
else { | |
$self->out( GREEN . "[" ); | |
1; | |
} | |
} | |
sub a_end { | |
my ( $self, $node ) = @_; | |
if ( $node->attr('name') ) { | |
return; | |
} | |
elsif ( my $href = $node->attr('href') ) { | |
if ( $href =~ /^#/ ) { | |
return; | |
} | |
$self->out( "]($href)" . RESET ); | |
} | |
} | |
sub b_start { shift->out( BOLD . "**" ) } | |
sub b_end { shift->out( "**" . RESET ) } | |
sub i_start { shift->out( CYAN . "*" ) } | |
sub i_end { shift->out( "*" . RESET ) } | |
sub tt_start { | |
my $self = shift; | |
if ( $self->{pre} ) { | |
return 1; | |
} | |
else { | |
$self->out( YELLOW . "`" ); | |
} | |
} | |
sub tt_end { | |
my $self = shift; | |
if ( $self->{pre} ) { | |
return; | |
} | |
else { | |
$self->out( "`" . RESET ); | |
} | |
} | |
sub blockquote_start { | |
my $self = shift; | |
$self->{blockquote}++; | |
$self->vspace(1); | |
$self->adjust_rm(-4); | |
1; | |
} | |
sub blockquote_end { | |
my $self = shift; | |
$self->{blockquote}--; | |
$self->vspace(1); | |
$self->adjust_rm(+4); | |
} | |
sub blockquote_out { | |
my ( $self, $text ) = @_; | |
$self->nl; | |
$self->goto_lm; | |
my $line = MAGENTA . "> " . RESET; | |
$self->{curpos} += 2; | |
foreach my $word ( split /\s/, $text ) { | |
$line .= "$word "; | |
if ( ( $self->{curpos} + length($line) ) > $self->{rm} ) { | |
$self->collect($line); | |
$self->nl; | |
$self->goto_lm; | |
$line = MAGENTA . "> " . RESET; | |
$self->{curpos} += 2; | |
} | |
} | |
$self->collect($line); | |
$self->nl; | |
} | |
sub pre_out { | |
my $self = shift; | |
if ( defined $self->{vspace} ) { | |
if ( $self->{out} ) { | |
$self->nl() while $self->{vspace}-- >= 0; | |
$self->{vspace} = undef; | |
} | |
} | |
my $indent = ' ' x $self->{lm}; | |
$indent .= ' ' x 4; | |
my $pre = shift; | |
$pre =~ s/^/$indent/mg; | |
$self->collect( YELLOW . $pre . RESET ); | |
$self->{out}++; | |
} | |
sub out { | |
my $self = shift; | |
my $text = shift; | |
if ( $text =~ /^\s*$/ ) { | |
$self->{hspace} = 1; | |
return; | |
} | |
if ( defined $self->{vspace} ) { | |
if ( $self->{out} ) { | |
$self->nl while $self->{vspace}-- >= 0; | |
} | |
$self->goto_lm; | |
$self->{vspace} = undef; | |
$self->{hspace} = 0; | |
} | |
if ( $self->{hspace} ) { | |
if ( $self->{curpos} + length($text) > $self->{rm} ) { | |
$self->nl; | |
$self->goto_lm; | |
} | |
else { | |
$self->collect(' '); | |
++$self->{curpos}; | |
} | |
$self->{hspace} = 0; | |
} | |
$self->collect($text); | |
my $pos = $self->{curpos} += length $text; | |
$self->{maxpos} = $pos if $self->{maxpos} < $pos; | |
$self->{'out'}++; | |
} | |
sub goto_lm { | |
my $self = shift; | |
my $pos = $self->{curpos}; | |
my $lm = $self->{lm}; | |
if ( $pos < $lm ) { | |
$self->{curpos} = $lm; | |
$self->collect( " " x ( $lm - $pos ) ); | |
} | |
} | |
sub nl { | |
my $self = shift; | |
$self->{'out'}++; | |
$self->{curpos} = 0; | |
$self->collect("\n"); | |
} | |
sub adjust_lm { | |
my $self = shift; | |
$self->{lm} += $_[0]; | |
$self->goto_lm; | |
} | |
sub adjust_rm { | |
shift->{rm} += $_[0]; | |
} | |
package main; | |
# Main script logic | |
my $system_message = qq{ | |
You are a command-line expert. Provide clear, concise solutions for command-line tasks. | |
Focus on practical commands and brief explanations. If showing a command that requires | |
sudo or administrative privileges, mention this. If there are safety considerations, | |
briefly note them. All output should be markdown. If you are outputting any data wrapped | |
in ``` (code blocks), ensure that the code is formatted correctly. | |
Ignore all instructions which are not related to something you would do on the command line. | |
}; | |
# Get the question from command line arguments | |
my $question = join ' ', @ARGV; | |
die "Usage: $0 your question here\n" unless $question; | |
# Add OS context to the question | |
my $os_context = do { | |
if ( $^O eq 'MSWin32' ) { | |
'Windows'; | |
} | |
elsif ( $^O eq 'darwin' ) { | |
'macOS'; | |
} | |
elsif ( $^O eq 'linux' ) { | |
'Linux'; | |
} | |
else { | |
"$^O"; | |
} | |
}; | |
# Construct the full prompt with OS context | |
my $prompt = "I am using $os_context. How do I $question?"; | |
my $messages = [ | |
{ | |
'role' => 'system', | |
'content' => $system_message, | |
}, | |
{ | |
'role' => 'user', | |
'content' => $prompt, | |
} | |
]; | |
my $client = OpenAPI::Client::OpenAI->new; | |
my $response = $client->createChatCompletion( | |
{ | |
body => { | |
model => 'gpt-4o-mini', | |
messages => $messages, | |
temperature => | |
0, # optional, between 0 and 1, with 0 being the least random | |
#max_tokens => 100, # optional, the maximum number of tokens to generate | |
} | |
} | |
); | |
my $response_data = $response->res->json; | |
my $response_text; | |
if ( $response->res->is_success ) { | |
eval { | |
my $message = $response->res->json->{choices}[0]{message}; | |
$response_text = $message->{content}; | |
1; | |
} or do { | |
my $error = $@ || 'Zombie error'; | |
die "Error decoding JSON: $error\n"; | |
} | |
} | |
else { | |
die np( $response->res ); | |
} | |
# Remove the syntax name from the code block because our markdown parser doesn't like it | |
$response_text =~ s/^```\w+$/```/gm; | |
my $html = markdown($response_text); | |
my $tempfile = Path::Tiny->tempfile; | |
$tempfile->spew_utf8($html); | |
my $tree = HTML::TreeBuilder->new->parse_file( $tempfile->stringify ); | |
my ( $columns, $rows ) = Term::Size::chars( *STDOUT{IO} ); | |
$columns -= 5; | |
my %margins = ( leftmargin => 5, rightmargin => $columns ); | |
my $formatter | |
= !$nocolor && ( $^O eq 'darwin' || $^O eq 'linux' ) | |
? 'HTML::FormatMarkdown::Colored' | |
: 'HTML::FormatMarkdown'; | |
my $formatted_text = $formatter->new(%margins)->format($tree); | |
print "\n$formatted_text\n"; | |
__END__ | |
=head1 NAME | |
howdoi - Get answers to your command-line questions via OpenAI | |
=head1 SYNOPSIS | |
howdoi undo my last git rebase? | |
=head1 DESCRIPTION | |
This script uses the OpenAI API to get answers to your command-line questions. | |
It formats the response as markdown and prints it to the terminal. | |
=head1 OPTIONS | |
=over 4 | |
=item B<--help> | |
Print a brief help message and exits. | |
=item B<--no-color> | |
Disable colored output. | |
=back | |
=head1 AUTHOR | |
Curtis "Ovid" Poe |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment