Skip to content

Instantly share code, notes, and snippets.

@Ovid
Last active December 17, 2024 05:46
Show Gist options
  • Save Ovid/ca6e73093ad281c93ca338b9cdb7b3c4 to your computer and use it in GitHub Desktop.
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
#!/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 . "![$alt]($src)" . 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