feat: supports functions in bash/js/python/ruby (#6)

This commit is contained in:
sigoden
2024-05-19 22:43:49 +08:00
committed by GitHub
parent be279dcd32
commit 03c4b69822
15 changed files with 543 additions and 92 deletions
+21 -4
View File
@@ -16,10 +16,14 @@ jobs:
all:
name: All
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: extractions/setup-crate@v1
with:
@@ -29,5 +33,18 @@ jobs:
- name: Check versions
run: argc version
- name: Check scripts
run: argc build
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
- name: Run Test
run: argc test
+4 -1
View File
@@ -1,5 +1,8 @@
/tmp
/functions.txt
/functions.txt.test
/functions.json
/bin
/.env
*.cmd
*.cmd
__pycache__
+199 -35
View File
@@ -3,60 +3,184 @@ set -e
# @meta dotenv
BIN_DIR=bin
LANG_CMDS=( \
"sh:bash" \
"js:node" \
"py:python" \
"rb:ruby" \
)
# @cmd Call the function
# @arg func![`_choice_func`] The function name
# @arg args~[?`_choice_func_args`] The function args
call() {
"./bin/$argc_func" "${argc_args[@]}"
basename="${argc_func%.*}"
lang="${argc_func##*.}"
func_path="./$lang/$basename.$lang"
if [[ ! -e "$func_path" ]]; then
_die "error: not found $argc_func"
fi
if [[ "$lang" == "sh" ]]; then
"$func_path" "${argc_args[@]}"
else
"$(_lang_to_cmd "$lang")" "./cmd/cmd.$lang" "$argc_func"
fi
}
# @cmd Build all artifacts
# @cmd Build the project
# @option --names-file=functions.txt Path to a file containing function filenames, one per line.
# This file specifies which function files to build.
# Example:
# get_current_weather.sh
# may_execute_js_code.js
build() {
if [[ -f functions.txt ]]; then
argc build-declarations-json
fi
if [[ "$OS" = "Windows_NT" ]]; then
argc build-win-shims
fi
argc build-declarations-json --names-file "${argc_names_file}"
argc build-bin --names-file "${argc_names_file}"
}
# @cmd Build declarations for specific functions
# @option --output=functions.json <FILE> Specify a file path to save the function declarations
# @option --names-file=functions.txt Specify a file containing function names
# @arg funcs*[`_choice_func`] The function names
# @cmd Build bin dir
# @option --names-file=functions.txt Path to a file containing function filenames, one per line.
build-bin() {
if [[ ! -f "$argc_names_file" ]]; then
_die "no found "$argc_names_file""
fi
mkdir -p "$BIN_DIR"
rm -rf "$BIN_DIR"/*
names=($(cat "$argc_names_file"))
invalid_names=()
for name in "${names[@]}"; do
basename="${name%.*}"
lang="${name##*.}"
func_file="$lang/$name"
if [[ -f "$func_file" ]]; then
if _is_win; then
bin_file="$BIN_DIR/$basename.cmd"
if [[ "$lang" == sh ]]; then
_build_win_sh > "$bin_file"
else
_build_win_lang $lang "$(_lang_to_cmd "$lang")" > "$bin_file"
fi
else
bin_file="$BIN_DIR/$basename"
if [[ "$lang" == sh ]]; then
ln -s "$PWD/$func_file" "$bin_file"
else
ln -s "$PWD/cmd/cmd.$lang" "$bin_file"
fi
fi
else
invalid_names+=("$name")
fi
done
if [[ -n "$invalid_names" ]]; then
_die "error: missing following functions: ${invalid_names[*]}"
fi
echo "Build bin"
}
# @cmd Build declarations.json
# @option --output=functions.json <FILE> Path to a json file to save function declarations
# @option --names-file=functions.txt Path to a file containing function filenames, one per line.
# @arg funcs*[`_choice_func`] The function filenames
build-declarations-json() {
set +e
if [[ "${#argc_funcs[@]}" -gt 0 ]]; then
names=("${argc_funcs[@]}" )
elif [[ -f "$argc_names_file" ]]; then
names=($(cat "$argc_names_file"))
fi
if [[ -z "$names" ]]; then
_die "error: no specific function"
_die "error: no function for building declarations.json"
fi
result=()
json_list=()
not_found_funcs=()
build_failed_funcs=()
for name in "${names[@]}"; do
result+=("$(build-func-declaration "$name")")
lang="${name##*.}"
func_file="$lang/$name"
if [[ ! -f "$func_file" ]]; then
not_found_funcs+=("$name")
continue;
fi
json_data="$("build-single-declaration" "$name")"
status=$?
if [ $status -eq 0 ]; then
json_list+=("$json_data")
else
build_failed_funcs+=("$name")
fi
done
echo "["$(IFS=,; echo "${result[*]}")"]" | jq '.' > "$argc_output"
if [[ -n "$not_found_funcs" ]]; then
_die "error: not found functions: ${not_found_funcs[*]}"
fi
if [[ -n "$build_failed_funcs" ]]; then
_die "error: invalid functions: ${build_failed_funcs[*]}"
fi
echo "Build $argc_output"
echo "["$(IFS=,; echo "${json_list[*]}")"]" | jq '.' > "$argc_output"
}
# @cmd Build declaration for a single function
# @cmd Build single declaration
# @arg func![`_choice_func`] The function name
build-func-declaration() {
argc --argc-export bin/$1 | _parse_declaration
build-single-declaration() {
func="$1"
lang="${func##*.}"
cmd="$(_lang_to_cmd "$lang")"
if [[ "$lang" == sh ]]; then
argc --argc-export "$lang/$func" | _parse_argc_declaration
else
LLM_FUNCTION_DECLARATE=1 "$cmd" "cmd/cmd.$lang" "$func"
fi
}
# @cmd Build shims for the functions
# Because Windows OS can't run bash scripts directly, we need to make a shim for each function
#
# @flag --clear Clear the shims
build-win-shims() {
funcs=($(_choice_func))
for func in "${funcs[@]}"; do
echo "Shim bin/${func}.cmd"
_win_shim > "bin/${func}.cmd"
done
# @cmd List functions that can be put into functions.txt
# Examples:
# argc --list-functions > functions.txt
# argc --list-functions --write
# argc --list-functions search_duckduckgo.sh >> functions.txt
# @flag -w --write Output to functions.txt
# @arg funcs*[`_choice_func`] The function filenames, list all available functions if not provided
list-functions() {
if [[ -n "$argc_write" ]]; then
_choice_func > functions.txt
echo "Write functions.txt"
else
_choice_func
fi
}
# @cmd Test the project
# @meta require-tools node,python,ruby
test() {
names_file=functions.txt.test
argc list-functions > "$names_file"
argc build --names-file "$names_file"
argc test-call-functions
}
# @cmd Test call functions
test-call-functions() {
if _is_win; then
ext=".cmd"
fi
"./bin/may_execute_command$ext" --command 'echo "bash works"'
argc call may_execute_command.sh --command 'echo "bash works"'
export LLM_FUNCTION_DATA='{"code":"console.log(\"javascript works\")"}'
"./bin/may_execute_js_code$ext"
argc call may_execute_js_code.js
export LLM_FUNCTION_DATA='{"code":"print(\"python works\")"}'
"./bin/may_execute_py_code$ext"
argc call may_execute_py_code.py
export LLM_FUNCTION_DATA='{"code":"puts \"ruby works\""}'
"./bin/may_execute_rb_code$ext"
argc call may_execute_rb_code.rb
}
# @cmd Install this repo to aichat functions_dir
@@ -80,7 +204,7 @@ version() {
curl --version | head -n 1
}
_parse_declaration() {
_parse_argc_declaration() {
jq -r '
def parse_description(flag_option):
if flag_option.describe == "" then
@@ -123,26 +247,66 @@ _parse_declaration() {
}'
}
_win_shim() {
_lang_to_cmd() {
match_lang="$1"
for item in "${LANG_CMDS[@]}"; do
lang="${item%:*}"
if [[ "$lang" == "$match_lang" ]]; then
echo "${item#*:}"
fi
done
}
_build_win_sh() {
cat <<-'EOF'
@echo off
setlocal
set "script_dir=%~dp0"
set "bin_dir=%~dp0"
for %%i in ("%bin_dir:~0,-1%") do set "script_dir=%%~dpi"
set "script_name=%~n0"
set "script_name=%script_name%.sh"
for /f "delims=" %%a in ('argc --argc-shell-path') do set "_bash_prog=%%a"
"%_bash_prog%" --noprofile --norc "%script_dir%\%script_name%" %*
"%_bash_prog%" --noprofile --norc "%script_dir%sh\%script_name%" %*
EOF
}
_build_win_lang() {
lang="$1"
cmd="$2"
cat <<-EOF
@echo off
setlocal
set "bin_dir=%~dp0"
for %%i in ("%bin_dir:~0,-1%") do set "script_dir=%%~dpi"
set "script_name=%~n0"
$cmd "%script_dir%cmd\cmd.$lang" "%script_name%.$lang" %*
EOF
}
_is_win() {
if [[ "$OS" == "Windows_NT" ]]; then
return 0
else
return 1
fi
}
_choice_func() {
ls -1 bin | grep -v '\.cmd'
for item in "${LANG_CMDS[@]}"; do
lang="${item%:*}"
ls -1 $lang | grep "\.$lang$"
done
}
_choice_func_args() {
args=( "${argc__positionals[@]}" )
argc --argc-compgen generic "bin/${args[0]}" "${args[@]}"
if [[ "${args[0]}" == *.sh ]]; then
argc --argc-compgen generic "sh/${args[0]}" "${args[@]}"
fi
}
_die() {
+128 -41
View File
@@ -1,6 +1,6 @@
# LLM Functions
This project allows you to enhance large language models (LLMs) with custom functions written in Bash. Imagine your LLM being able to execute system commands, access web APIs, or perform other complex tasks all triggered by simple, natural language prompts.
This project allows you to enhance large language models (LLMs) with custom functions written in Bash/Js/Python/Ruby. Imagine your LLM being able to execute system commands, access web APIs, or perform other complex tasks all triggered by simple, natural language prompts.
## Prerequisites
@@ -18,18 +18,11 @@ Make sure you have the following tools installed:
git clone https://github.com/sigoden/llm-functions
```
**2. Build function declarations:**
**2. Build function declarations file and bin dir:**
Before using the functions, you need to generate a `./functions.json` file that describes the available functions for the LLM.
```sh
argc build-declarations <function_names>...
```
Replace `<function_names>...` with the actual names of your functions. Go to the [./bin](https://github.com/sigoden/llm-functions/tree/main/bin) directory for valid function names.
> 💡 You can also create a `./functions.txt` file with each function name on a new line, Once done, simply run `argc build-declarations` without specifying the function names to automatically use the ones listed in.
First, create a `./functions.txt` file with each function name on a new line.
Then, run `argc build` to build function declarations file `./functions.json` and bin dir `./bin/`.
**3. Configure your AIChat:**
@@ -55,36 +48,6 @@ Now you can interact with your LLM using natural language prompts that trigger y
![image](https://github.com/sigoden/llm-functions/assets/4012553/867b7b2a-25fb-4c74-9b66-3701eaabbd1f)
## Writing Your Own Functions
Create a new Bash script in the `./bin` directory with the name of your function (e.g., `get_current_weather`) Follow the structure demonstrated in existing examples. For instance:
```sh
# @describe Get the current weather in a given location.
# @option --location! The city and state, e.g. San Francisco, CA
main() {
curl "https://wttr.in/$(echo "$argc_location" | sed 's/ /+/g')?format=4&M"
}
eval "$(argc --argc-eval "$0" "$@")"
```
The relationship between flags/options and parameters in function declarations is as follows:
```sh
# @flag --boolean Parameter `{"type": "boolean"}`
# @option --string Parameter `{"type": "string"}`
# @option --string-enum[foo|bar] Parameter `{"type": "string", "enum": ["foo", "bar"]}`
# @option --integer <INT> Parameter `{"type": "integer"}`
# @option --number <NUM> Parameter `{"type": "number"}`
# @option --array* <VALUE> Parameter `{"type": "array", "items": {"type":"string"}}`
# @option --scalar-required! Use `!` to mark a scalar parameter as required.
# @option --array-required+ Use `+` to mark a array parameter as required
```
**After creating your function, don't forget to rebuild the function declarations.**
## Function Types
### Retrieve Type
@@ -103,6 +66,130 @@ AIChat will ask permission before running the function.
**AIChat categorizes functions starting with `may_` as `execute type` and all others as `retrieve type`.**
## Writing Your Own Functions
The project supports write functions in bash/js/python.
### Bash
Create a new bashscript (.e.g. `may_execute_command.sh`) in the [./sh](./sh/) directory.
```sh
#!/usr/bin/env bash
set -e
# @describe Executes a shell command.
# @option --command~ Command to execute, such as `ls -la`
main() {
eval $argc_shell_command
}
eval "$(argc --argc-eval "$0" "$@")"
```
`llm-functions` will automatic generate function declaration.json from [comment tags](https://github.com/sigoden/argc?tab=readme-ov-file#comment-tags).
The relationship between comment tags and parameters in function declarations is as follows:
```sh
# @flag --boolean Parameter `{"type": "boolean"}`
# @option --string Parameter `{"type": "string"}`
# @option --string-enum[foo|bar] Parameter `{"type": "string", "enum": ["foo", "bar"]}`
# @option --integer <INT> Parameter `{"type": "integer"}`
# @option --number <NUM> Parameter `{"type": "number"}`
# @option --array* <VALUE> Parameter `{"type": "array", "items": {"type":"string"}}`
# @option --scalar-required! Use `!` to mark a scalar parameter as required.
# @option --array-required+ Use `+` to mark a array parameter as required
```
### Javascript
Create a new javascript (.e.g. `may_execute_command.js`) in the [./js](./js/) directory.
```js
exports.declarate = function declarate() {
return {
"name": "may_execute_js_code",
"description": "Runs the javascript code in node.js.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Javascript code to execute, such as `console.log(\"hello world\")`"
}
},
"required": [
"code"
]
}
}
}
exports.execute = function execute(data) {
eval(data.code)
}
```
### Python
Create a new python script in the [./py](./py/) directory (e.g., `may_execute_py_code.py`).
```py
def declarate():
return {
"name": "may_execute_py_code",
"description": "Runs the python code.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "python code to execute, such as `print(\"hello world\")`"
}
},
"required": [
"code"
]
}
}
def execute(data):
exec(data["code"])
```
### Ruby
Create a new ruby script in the [./rb](./rb/) directory (e.g., `may_execute_rb_code.rb`).
```rb
def declarate
{
"name": "may_execute_rb_code",
"description": "Runs the ruby code.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Ruby code to execute, such as `puts \"hello world\"`"
}
},
"required": [
"code"
]
}
}
end
def execute(data)
eval(data["code"])
end
```
## License
The project is under the MIT License, Refer to the [LICENSE](https://github.com/sigoden/llm-functions/blob/main/LICENSE) file for detailed information.
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -e
# @describe Executes a shell command.
# @option --shell-command~ "Shell command to execute, such as `ls -la`"
main() {
eval $argc_shell_command
}
eval "$(argc --argc-eval "$0" "$@")"
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
function loadModule() {
const path = require("path");
let func_name = process.argv[1];
if (func_name.endsWith("cmd.js")) {
func_name = process.argv[2]
} else {
func_name = path.basename(func_name)
}
if (!func_name.endsWith(".js")) {
func_name += '.js'
}
const func_path = path.resolve(__dirname, `../js/${func_name}`)
try {
return require(func_path);
} catch {
console.log(`Invalid js function: ${func_name}`)
process.exit(1)
}
}
if (process.env["LLM_FUNCTION_DECLARATE"]) {
const { declarate } = loadModule();
console.log(JSON.stringify(declarate(), null, 2))
} else {
let data = null;
try {
data = JSON.parse(process.env["LLM_FUNCTION_DATA"])
} catch {
console.log("Invalid LLM_FUNCTION_DATA")
process.exit(1)
}
const { execute } = loadModule();
execute(data)
}
Executable
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python
import os
import json
import sys
import importlib.util
def load_module(func_name):
base_dir = os.path.dirname(os.path.abspath(__file__))
func_path = os.path.join(base_dir, f"../py/{func_name}")
if os.path.exists(func_path):
spec = importlib.util.spec_from_file_location(func_name, func_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
else:
print(f"Invalid py function: {func_name}")
sys.exit(1)
func_name = sys.argv[0]
if func_name.endswith("cmd.py"):
func_name = sys.argv[1]
else:
func_name = os.path.basename(func_name)
if not func_name.endswith(".py"):
func_name += ".py"
if os.getenv("LLM_FUNCTION_DECLARATE"):
module = load_module(func_name)
declarate = getattr(module, 'declarate')
print(json.dumps(declarate(), indent=2))
else:
data = None
try:
data = json.loads(os.getenv("LLM_FUNCTION_DATA"))
except (json.JSONDecodeError, TypeError):
print("Invalid LLM_FUNCTION_DATA")
sys.exit(1)
module = load_module(func_name)
execute = getattr(module, 'execute')
execute(data)
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env ruby
require 'json'
require 'pathname'
def load_module
if __FILE__.end_with?("cmd.rb")
func_name = ARGV[0]
else
func_name = Pathname.new(__FILE__).basename.to_s
end
func_name += '.rb' unless func_name.end_with?('.rb')
func_path = File.expand_path("../rb/#{func_name}", __dir__)
begin
return require_relative func_path
rescue LoadError
puts "Invalid ruby function: #{func_name}"
exit 1
end
end
if ENV["LLM_FUNCTION_DECLARATE"]
declarate = load_module.method(:declarate)
puts JSON.pretty_generate(declarate.call)
else
begin
data = JSON.parse(ENV["LLM_FUNCTION_DATA"])
rescue JSON::ParserError
puts "Invalid LLM_FUNCTION_DATA"
exit 1
end
execute = load_module.method(:execute)
execute.call(data)
end
+22
View File
@@ -0,0 +1,22 @@
exports.declarate = function declarate() {
return {
"name": "may_execute_js_code",
"description": "Runs the javascript code in node.js.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Javascript code to execute, such as `console.log(\"hello world\")`"
}
},
"required": [
"code"
]
}
}
}
exports.execute = function execute(data) {
eval(data.code)
}
+21
View File
@@ -0,0 +1,21 @@
def declarate():
return {
"name": "may_execute_py_code",
"description": "Runs the python code.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Python code to execute, such as `print(\"hello world\")`"
}
},
"required": [
"code"
]
}
}
def execute(data):
exec(data["code"])
+22
View File
@@ -0,0 +1,22 @@
def declarate
{
"name": "may_execute_rb_code",
"description": "Runs the ruby code.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Ruby code to execute, such as `puts \"hello world\"`"
}
},
"required": [
"code"
]
}
}
end
def execute(data)
eval(data["code"])
end
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
# @describe Executes a shell command.
# @option --command~ Command to execute, such as `ls -la`
main() {
eval "$argc_command"
}
eval "$(argc --argc-eval "$0" "$@")"