feat: rewrite to accept json data from cli args other than env var (#7)

* update readme
This commit is contained in:
sigoden
2024-05-21 08:26:47 +08:00
committed by GitHub
parent e6642b5898
commit ef43b22a8e
8 changed files with 266 additions and 183 deletions
+48 -120
View File
@@ -13,20 +13,13 @@ LANG_CMDS=( \
) )
# @cmd Call the function # @cmd Call the function
# @arg func![`_choice_func`] The function name # @arg cmd![`_choice_cmd`] The function command
# @arg args~[?`_choice_func_args`] The function args # @arg json The json data
call() { call() {
basename="${argc_func%.*}" if _is_win; then
lang="${argc_func##*.}" ext=".cmd"
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 fi
"$BIN_DIR/$argc_cmd$ext" "$argc_json"
} }
# @cmd Build the project # @cmd Build the project
@@ -49,7 +42,7 @@ build-bin() {
mkdir -p "$BIN_DIR" mkdir -p "$BIN_DIR"
rm -rf "$BIN_DIR"/* rm -rf "$BIN_DIR"/*
names=($(cat "$argc_names_file")) names=($(cat "$argc_names_file"))
invalid_names=() not_found_funcs=()
for name in "${names[@]}"; do for name in "${names[@]}"; do
basename="${name%.*}" basename="${name%.*}"
lang="${name##*.}" lang="${name##*.}"
@@ -57,27 +50,21 @@ build-bin() {
if [[ -f "$func_file" ]]; then if [[ -f "$func_file" ]]; then
if _is_win; then if _is_win; then
bin_file="$BIN_DIR/$basename.cmd" bin_file="$BIN_DIR/$basename.cmd"
if [[ "$lang" == sh ]]; then _build_win_shim $lang > "$bin_file"
_build_win_sh > "$bin_file"
else
_build_win_lang $lang "$(_lang_to_cmd "$lang")" > "$bin_file"
fi
else else
bin_file="$BIN_DIR/$basename" bin_file="$BIN_DIR/$basename"
if [[ "$lang" == sh ]]; then ln -s "$PWD/cmd/cmd.$lang" "$bin_file"
ln -s "$PWD/$func_file" "$bin_file"
else
ln -s "$PWD/cmd/cmd.$lang" "$bin_file"
fi
fi fi
else else
invalid_names+=("$name") not_found_funcs+=("$name")
fi fi
done done
if [[ -n "$invalid_names" ]]; then if [[ -n "$not_found_funcs" ]]; then
_die "error: missing following functions: ${invalid_names[*]}" _die "error: not founds functions: ${not_found_funcs[*]}"
fi fi
echo "Build bin" for name in "$BIN_DIR"/*; do
echo "Build $name"
done
} }
# @cmd Build declarations.json # @cmd Build declarations.json
@@ -125,11 +112,7 @@ build-single-declaration() {
func="$1" func="$1"
lang="${func##*.}" lang="${func##*.}"
cmd="$(_lang_to_cmd "$lang")" cmd="$(_lang_to_cmd "$lang")"
if [[ "$lang" == sh ]]; then LLM_FUNCTION_ACTION=declarate "$cmd" "cmd/cmd.$lang" "$func"
argc --argc-export "$lang/$func" | _parse_argc_declaration
else
LLM_FUNCTION_DECLARATE=1 "$cmd" "cmd/cmd.$lang" "$func"
fi
} }
# @cmd List functions that can be put into functions.txt # @cmd List functions that can be put into functions.txt
@@ -146,36 +129,37 @@ test() {
func_names_file=functions.txt.test func_names_file=functions.txt.test
argc list-functions > "$func_names_file" argc list-functions > "$func_names_file"
argc build --names-file "$func_names_file" argc build --names-file "$func_names_file"
argc test-call-functions argc test-functions
rm -rf "$func_names_file" rm -rf "$func_names_file"
} }
# @cmd Test call functions # @cmd Test call functions
test-call-functions() { test-functions() {
if _is_win; then if _is_win; then
ext=".cmd" ext=".cmd"
fi fi
"./bin/may_execute_command$ext" --command 'echo "bash works"' test_cases=( \
argc call may_execute_command.sh --command 'echo "bash works"' 'sh#may_execute_command#{"command":"echo \"✓\""}' \
'js#may_execute_js_code#{"code":"console.log(\"✓\")"}' \
'py#may_execute_py_code#{"code":"print(\"✓\")"}' \
'rb#may_execute_rb_code#{"code":"puts \"✓\""}' \
)
if command -v node &> /dev/null; then for test_case in "${test_cases[@]}"; do
export LLM_FUNCTION_DATA='{"code":"console.log(\"javascript works\")"}' IFS='#' read -r lang func data <<<"${test_case}"
"./bin/may_execute_js_code$ext" cmd="$(_lang_to_cmd "$lang")"
argc call may_execute_js_code.js cmd_path="$BIN_DIR/$func$ext"
fi if command -v "$cmd" &> /dev/null; then
"$cmd_path" "$data" | {
if command -v python &> /dev/null; then echo "Test $cmd_path: $(cat)"
export LLM_FUNCTION_DATA='{"code":"print(\"python works\")"}' }
"./bin/may_execute_py_code$ext" if ! _is_win; then
argc call may_execute_py_code.py "$cmd" "cmd/cmd.$lang" "$func" "$data" | {
fi echo "Test $cmd cmd/cmd.$lang $func: $(cat)"
}
if command -v ruby &> /dev/null; then fi
export LLM_FUNCTION_DATA='{"code":"puts \"ruby works\""}' fi
"./bin/may_execute_rb_code$ext" done
argc call may_execute_rb_code.rb
fi
} }
# @cmd Install this repo to aichat functions_dir # @cmd Install this repo to aichat functions_dir
@@ -199,49 +183,6 @@ version() {
curl --version | head -n 1 curl --version | head -n 1
} }
_parse_argc_declaration() {
jq -r '
def parse_description(flag_option):
if flag_option.describe == "" then
{}
else
{ "description": flag_option.describe }
end;
def parse_enum(flag_option):
if flag_option.choice.type == "Values" then
{ "enum": flag_option.choice.data }
else
{}
end;
def parse_property(flag_option):
[
{ condition: (flag_option.flag == true), result: { type: "boolean" } },
{ condition: (flag_option.multiple_occurs == true), result: { type: "array", items: { type: "string" } } },
{ condition: (flag_option.notations[0] == "INT"), result: { type: "integer" } },
{ condition: (flag_option.notations[0] == "NUM"), result: { type: "number" } },
{ condition: true, result: { type: "string" } } ]
| map(select(.condition) | .result) | first
| (. + parse_description(flag_option))
| (. + parse_enum(flag_option))
;
def parse_parameter(flag_options):
{
type: "object",
properties: (reduce flag_options[] as $item ({}; . + { ($item.id | sub("-"; "_"; "g")): parse_property($item) })),
required: [flag_options[] | select(.required == true) | .id],
};
{
name: (.name | sub("-"; "_"; "g")),
description: .describe,
parameters: parse_parameter([.flag_options[] | select(.id != "help" and .id != "version")])
}'
}
_lang_to_cmd() { _lang_to_cmd() {
match_lang="$1" match_lang="$1"
for item in "${LANG_CMDS[@]}"; do for item in "${LANG_CMDS[@]}"; do
@@ -252,24 +193,14 @@ _lang_to_cmd() {
done done
} }
_build_win_sh() { _build_win_shim() {
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"
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%sh\%script_name%" %*
EOF
}
_build_win_lang() {
lang="$1" lang="$1"
cmd="$2" cmd="$(_lang_to_cmd "$lang")"
if [[ "$lang" == "sh" ]]; then
run="\"$(cygpath -w "$(which $cmd)")\" --noprofile --norc"
else
run="\"$(cygpath -w "$(which $cmd)")\""
fi
cat <<-EOF cat <<-EOF
@echo off @echo off
setlocal setlocal
@@ -278,7 +209,7 @@ set "bin_dir=%~dp0"
for %%i in ("%bin_dir:~0,-1%") do set "script_dir=%%~dpi" for %%i in ("%bin_dir:~0,-1%") do set "script_dir=%%~dpi"
set "script_name=%~n0" set "script_name=%~n0"
$cmd "%script_dir%cmd\cmd.$lang" "%script_name%.$lang" %* $run "%script_dir%cmd\cmd.$lang" "%script_name%.$lang" %*
EOF EOF
} }
@@ -300,11 +231,8 @@ _choice_func() {
done done
} }
_choice_func_args() { _choice_cmd() {
args=( "${argc__positionals[@]}" ) ls -1 "$BIN_DIR" | sed -e 's/\.cmd$//'
if [[ "${args[0]}" == *.sh ]]; then
argc --argc-compgen generic "sh/${args[0]}" "${args[@]}"
fi
} }
_die() { _die() {
+9 -4
View File
@@ -1,6 +1,6 @@
# LLM Functions # LLM Functions
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. 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 ## Prerequisites
@@ -46,7 +46,8 @@ AIChat will automatically load `functions.json` and execute functions located in
Now you can interact with your LLM using natural language prompts that trigger your defined functions. Now you can interact with your LLM using natural language prompts that trigger your defined functions.
![image](https://github.com/sigoden/llm-functions/assets/4012553/867b7b2a-25fb-4c74-9b66-3701eaabbd1f) ![function-showcase](https://github.com/sigoden/llm-functions/assets/4012553/391867dd-577c-4aaa-9ff2-c9e67fb0f3a3)
## Function Types ## Function Types
@@ -56,13 +57,17 @@ The function returns JSON data to LLM for further processing.
AIChat does not ask permission to run the function or print the output. AIChat does not ask permission to run the function or print the output.
![retrieve-type-showcase](https://github.com/sigoden/llm-functions/assets/4012553/7e628834-9863-444a-bad8-7b51bfb18dff)
### Execute Type ### Execute Type
The function does not return data to LLM. Instead, they enable more complex actions, such as showing a progress bar or running a TUI application. The function does not have to return JSON data.
The function can perform dangerous tasks like creating/deleting files, changing network adapter, and setting a scheduled task...
AIChat will ask permission before running the function. AIChat will ask permission before running the function.
![image](https://github.com/sigoden/aichat/assets/4012553/711067b8-dd23-443d-840a-5556697ab075) ![execute-type-showcase](https://github.com/sigoden/llm-functions/assets/4012553/1dbc345f-daf9-4d65-a49f-3df8c7df1727)
**AIChat categorizes functions starting with `may_` as `execute type` and all others as `retrieve type`.** **AIChat categorizes functions starting with `may_` as `execute type` and all others as `retrieve type`.**
+38 -19
View File
@@ -1,36 +1,55 @@
#!/usr/bin/env node #!/usr/bin/env node
function loadModule() { const path = require("path");
const path = require("path");
let func_name = process.argv[1]; function parseArgv() {
if (func_name.endsWith("cmd.js")) { let func_file = process.argv[1];
func_name = process.argv[2] let func_data = null;
if (func_file.endsWith("cmd.js")) {
func_file = process.argv[2]
func_data = process.argv[3]
} else { } else {
func_name = path.basename(func_name) func_file = path.basename(func_file)
func_data = process.argv[2];
} }
if (!func_name.endsWith(".js")) {
func_name += '.js' if (!func_file.endsWith(".js")) {
func_file += '.js'
} }
const func_path = path.resolve(__dirname, `../js/${func_name}`)
return [func_file, func_data]
}
function loadFunc(func_file) {
const func_path = path.resolve(__dirname, `../js/${func_file}`)
try { try {
return require(func_path); return require(func_path);
} catch { } catch {
console.log(`Invalid js function: ${func_name}`) console.log(`Invalid function: ${func_file}`)
process.exit(1) process.exit(1)
} }
} }
if (process.env["LLM_FUNCTION_DECLARATE"]) { const [func_file, func_data] = parseArgv();
const { declarate } = loadModule();
if (process.env["LLM_FUNCTION_ACTION"] == "declarate") {
const { declarate } = loadFunc(func_file);
console.log(JSON.stringify(declarate(), null, 2)) console.log(JSON.stringify(declarate(), null, 2))
} else { } else {
let data = null; if (!func_data) {
try { console.log("No json data");
data = JSON.parse(process.env["LLM_FUNCTION_DATA"])
} catch {
console.log("Invalid LLM_FUNCTION_DATA")
process.exit(1) process.exit(1)
} }
const { execute } = loadModule();
execute(data) let args;
try {
args = JSON.parse(func_data)
} catch {
console.log("Invalid json data")
process.exit(1)
}
const { execute } = loadFunc(func_file);
execute(args)
} }
+33 -22
View File
@@ -5,39 +5,50 @@ import json
import sys import sys
import importlib.util import importlib.util
def load_module(func_name): def parse_argv():
func_file = sys.argv[0]
func_data = None
if func_file.endswith("cmd.py"):
func_file = sys.argv[1] if len(sys.argv) > 1 else None
func_data = sys.argv[2] if len(sys.argv) > 2 else None
else:
func_file = os.path.basename(func_file)
func_data = sys.argv[1] if len(sys.argv) > 1 else None
if not func_file.endswith(".py"):
func_file += ".py"
return func_file, func_data
def load_func(func_file):
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
func_path = os.path.join(base_dir, f"../py/{func_name}") func_path = os.path.join(base_dir, f"../py/{func_file}")
if os.path.exists(func_path): if os.path.exists(func_path):
spec = importlib.util.spec_from_file_location(func_name, func_path) spec = importlib.util.spec_from_file_location(func_file, func_path)
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
return module return module
else: else:
print(f"Invalid py function: {func_name}") print(f"Invalid function: {func_file}")
sys.exit(1) sys.exit(1)
func_name = sys.argv[0] func_file, func_data = parse_argv()
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"): if os.getenv("LLM_FUNCTION_ACTION") == "declarate":
func_name += ".py" module = load_func(func_file)
print(json.dumps(module.declarate(), indent=2))
if os.getenv("LLM_FUNCTION_DECLARATE"):
module = load_module(func_name)
declarate = getattr(module, 'declarate')
print(json.dumps(declarate(), indent=2))
else: else:
data = None if not func_data:
print("No json data")
sys.exit(1)
args = None
try: try:
data = json.loads(os.getenv("LLM_FUNCTION_DATA")) args = json.loads(func_data)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
print("Invalid LLM_FUNCTION_DATA") print("Invalid json data")
sys.exit(1) sys.exit(1)
module = load_module(func_name) module = load_func(func_file)
execute = getattr(module, 'execute') module.execute(args)
execute(data)
+35 -17
View File
@@ -3,34 +3,52 @@
require 'json' require 'json'
require 'pathname' require 'pathname'
def load_module def parse_argv
if __FILE__.end_with?("cmd.rb") func_file = __FILE__
func_name = ARGV[0] func_data = nil
if func_file.end_with?("cmd.rb")
func_file = ARGV[0]
func_data = ARGV[1]
else else
func_name = Pathname.new(__FILE__).basename.to_s func_file = File.basename(func_file)
func_data = ARGV[0]
end end
func_name += '.rb' unless func_name.end_with?('.rb') func_file += '.rb' unless func_file.end_with?(".rb")
func_path = File.expand_path("../rb/#{func_name}", __dir__)
[func_file, func_data]
end
def load_func(func_file)
func_path = File.expand_path("../rb/#{func_file}", __dir__)
begin begin
return require_relative func_path require func_path
rescue LoadError rescue LoadError
puts "Invalid ruby function: #{func_name}" puts "Invalid function: #{func_file}"
exit 1 exit 1
end end
end end
if ENV["LLM_FUNCTION_DECLARATE"] func_file, func_data = parse_argv
declarate = load_module.method(:declarate)
puts JSON.pretty_generate(declarate.call) if ENV["LLM_FUNCTION_ACTION"] == "declarate"
load_func(func_file)
puts JSON.pretty_generate(declarate)
else else
begin if func_data.nil?
data = JSON.parse(ENV["LLM_FUNCTION_DATA"]) puts "No json data"
rescue JSON::ParserError
puts "Invalid LLM_FUNCTION_DATA"
exit 1 exit 1
end end
execute = load_module.method(:execute)
execute.call(data) begin
args = JSON.parse(func_data)
rescue JSON::ParserError
puts "Invalid json data"
exit 1
end
load_func(func_file)
execute(args)
end end
Executable
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -e
if [[ "$0" == *cmd.sh ]]; then
FUNC_FILE="$1"
FUNC_DATA="$2"
else
FUNC_FILE="$(basename "$0")"
FUNC_DATA="$1"
fi
if [[ "$FUNC_FILE" != *'.sh' ]]; then
FUNC_FILE="$FUNC_FILE.sh"
fi
PROJECT_DIR="$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/.." &> /dev/null && pwd)"
FUNC_FILE="$PROJECT_DIR/sh/$FUNC_FILE"
if [[ "$OS" == "Windows_NT" ]]; then
FUNC_FILE="$(cygpath -w "$FUNC_FILE")"
fi
if [[ "$LLM_FUNCTION_ACTION" == "declarate" ]]; then
argc --argc-export "$FUNC_FILE" | \
jq -r '
def parse_description(flag_option):
if flag_option.describe == "" then
{}
else
{ "description": flag_option.describe }
end;
def parse_enum(flag_option):
if flag_option.choice.type == "Values" then
{ "enum": flag_option.choice.data }
else
{}
end;
def parse_property(flag_option):
[
{ condition: (flag_option.flag == true), result: { type: "boolean" } },
{ condition: (flag_option.multiple_occurs == true), result: { type: "array", items: { type: "string" } } },
{ condition: (flag_option.notations[0] == "INT"), result: { type: "integer" } },
{ condition: (flag_option.notations[0] == "NUM"), result: { type: "number" } },
{ condition: true, result: { type: "string" } } ]
| map(select(.condition) | .result) | first
| (. + parse_description(flag_option))
| (. + parse_enum(flag_option))
;
def parse_parameter(flag_options):
{
type: "object",
properties: (reduce flag_options[] as $item ({}; . + { ($item.id | sub("-"; "_"; "g")): parse_property($item) })),
required: [flag_options[] | select(.required == true) | .id],
};
{
name: (.name | sub("-"; "_"; "g")),
description: .describe,
parameters: parse_parameter([.flag_options[] | select(.id != "help" and .id != "version")])
}'
else
if [[ -z "$FUNC_DATA" ]]; then
echo "No json data"
exit 1
fi
data="$(
echo "$FUNC_DATA" | \
jq -r '
to_entries | .[] |
(.key | split("_") | join("-")) as $key |
if .value | type == "array" then
.value | .[] | "--\($key)\n\(.)"
elif .value | type == "boolean" then
if .value then "--\($key)" else "" end
else
"--\($key)\n\(.value)"
end' | \
tr -d '\r'
)" || {
echo "Invalid json data"
exit 1
}
while IFS= read -r line; do
ARGS+=("$line")
done <<< "$data"
"$FUNC_FILE" "${ARGS[@]}"
fi
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
# @describe Get the current time.
main() {
date
}
eval "$(argc --argc-eval "$0" "$@")"
+1 -1
View File
@@ -2,7 +2,7 @@
set -e set -e
# @describe Executes a shell command. # @describe Executes a shell command.
# @option --command~ Command to execute, such as `ls -la` # @option --command! Command to execute, such as `ls -la`
main() { main() {
eval "$argc_command" eval "$argc_command"