stash
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
.config.json
|
||||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
9
.idea/NyxTexCodingAgent.iml
generated
Normal file
9
.idea/NyxTexCodingAgent.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
11
.idea/go.imports.xml
generated
Normal file
11
.idea/go.imports.xml
generated
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GoImports">
|
||||||
|
<option name="excludedPackages">
|
||||||
|
<array>
|
||||||
|
<option value="github.com/pkg/errors" />
|
||||||
|
<option value="golang.org/x/net/context" />
|
||||||
|
</array>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/NyxTexCodingAgent.iml" filepath="$PROJECT_DIR$/.idea/NyxTexCodingAgent.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
BIN
NyxTexCodingAgent
Executable file
BIN
NyxTexCodingAgent
Executable file
Binary file not shown.
41
build.sh
Executable file
41
build.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_NAME="nyxtex"
|
||||||
|
OUT_DIR="dist"
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
VERSION="${VERSION:-$(date -u +%Y%m%d-%H%M%S)}"
|
||||||
|
LDFLAGS="-s -w"
|
||||||
|
|
||||||
|
TARGETS=(
|
||||||
|
"darwin/amd64"
|
||||||
|
"darwin/arm64"
|
||||||
|
"linux/amd64"
|
||||||
|
"linux/arm64"
|
||||||
|
"linux/386"
|
||||||
|
"windows/amd64"
|
||||||
|
"windows/arm64"
|
||||||
|
"freebsd/amd64"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Building $APP_NAME version=$VERSION -> $OUT_DIR/"
|
||||||
|
|
||||||
|
for target in "${TARGETS[@]}"; do
|
||||||
|
goos="${target%/*}"
|
||||||
|
goarch="${target#*/}"
|
||||||
|
|
||||||
|
ext=""
|
||||||
|
if [ "$goos" = "windows" ]; then
|
||||||
|
ext=".exe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
out="$OUT_DIR/${APP_NAME}-${VERSION}-${goos}-${goarch}${ext}"
|
||||||
|
echo " -> $out"
|
||||||
|
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \
|
||||||
|
go build -trimpath -ldflags "$LDFLAGS" -o "$out" .
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
94
config.go
Normal file
94
config.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadConfig reads .env and .config.json from root (if present) and sets any
|
||||||
|
// keys that aren't already in the process environment. Real env vars win.
|
||||||
|
// Recognized keys: OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL.
|
||||||
|
func loadConfig(root string) error {
|
||||||
|
if err := loadEnvFile(filepath.Join(root, ".env")); err != nil {
|
||||||
|
return fmt.Errorf(".env: %w", err)
|
||||||
|
}
|
||||||
|
if err := loadJSONConfig(filepath.Join(root, ".config.json")); err != nil {
|
||||||
|
return fmt.Errorf(".config.json: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEnvFile(path string) error {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
lineNo := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNo++
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "export ") {
|
||||||
|
line = strings.TrimPrefix(line, "export ")
|
||||||
|
}
|
||||||
|
eq := strings.IndexByte(line, '=')
|
||||||
|
if eq <= 0 {
|
||||||
|
return fmt.Errorf("line %d: missing '='", lineNo)
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:eq])
|
||||||
|
val := strings.TrimSpace(line[eq+1:])
|
||||||
|
val = unquote(val)
|
||||||
|
setIfEmpty(key, val)
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadJSONConfig(path string) error {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for k, v := range m {
|
||||||
|
setIfEmpty(k, v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setIfEmpty(key, val string) {
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := os.LookupEnv(key); ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Setenv(key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquote(s string) string {
|
||||||
|
if len(s) >= 2 {
|
||||||
|
first, last := s[0], s[len(s)-1]
|
||||||
|
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
|
||||||
|
return s[1 : len(s)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
112
main.go
Normal file
112
main.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const systemPrompt = `You are a coding assistant running inside a CLI. You have file tools scoped
|
||||||
|
to the current project directory. All paths are relative to the project root; absolute paths
|
||||||
|
and paths that escape the root (via "..") will be rejected. Prefer listing and reading before
|
||||||
|
editing. Keep replies concise.`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error getting working directory:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := loadConfig(root); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := os.Getenv("OPENAI_API_KEY")
|
||||||
|
baseURL := getEnvDefault("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||||
|
model := getEnvDefault("OPENAI_MODEL", "gpt-4o-mini")
|
||||||
|
|
||||||
|
if apiKey == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "OPENAI_API_KEY is not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewClient(baseURL, apiKey, model)
|
||||||
|
tools := toolDefinitions()
|
||||||
|
|
||||||
|
messages := []Message{{Role: "system", Content: systemPrompt}}
|
||||||
|
|
||||||
|
fmt.Printf("NyxTex agent — model=%s root=%s\n", model, root)
|
||||||
|
fmt.Println("Type your request. Empty line to submit. Ctrl+D or /exit to quit.")
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
for {
|
||||||
|
fmt.Print("\n> ")
|
||||||
|
input, err := readUserInput(reader)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if input == "/exit" || input == "/quit" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, Message{Role: "user", Content: input})
|
||||||
|
|
||||||
|
if err := runTurn(context.Background(), client, tools, root, &messages); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUserInput(r *bufio.Reader) (string, error) {
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil && line == "" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTurn sends the conversation and handles any tool-call loop until the
|
||||||
|
// assistant produces a plain text reply.
|
||||||
|
func runTurn(ctx context.Context, client *Client, tools []Tool, root string, messages *[]Message) error {
|
||||||
|
for {
|
||||||
|
msg, err := client.Chat(ctx, *messages, tools)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*messages = append(*messages, msg)
|
||||||
|
|
||||||
|
if len(msg.ToolCalls) == 0 {
|
||||||
|
if strings.TrimSpace(msg.Content) != "" {
|
||||||
|
fmt.Println(msg.Content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
fmt.Printf(" [tool] %s %s\n", tc.Function.Name, tc.Function.Arguments)
|
||||||
|
result := runTool(root, tc.Function.Name, tc.Function.Arguments)
|
||||||
|
*messages = append(*messages, Message{
|
||||||
|
Role: "tool",
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Content: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvDefault(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
106
openai.go
Normal file
106
openai.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FunctionCall.Arguments is a JSON-encoded string per the OpenAI spec, not a
|
||||||
|
// JSON object — keep it as a string and decode when dispatching.
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function FunctionCall `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunctionCall struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
Tools []Tool `json:"tools,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message Message `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
APIKey string
|
||||||
|
Model string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL, apiKey, model string) *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
APIKey: apiKey,
|
||||||
|
Model: model,
|
||||||
|
HTTP: &http.Client{Timeout: 120 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Chat(ctx context.Context, messages []Message, tools []Tool) (Message, error) {
|
||||||
|
body, err := json.Marshal(chatRequest{Model: c.Model, Messages: messages, Tools: tools})
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return Message{}, fmt.Errorf("api error %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed chatResponse
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return Message{}, fmt.Errorf("decode response: %w; body=%s", err, string(raw))
|
||||||
|
}
|
||||||
|
if parsed.Error != nil {
|
||||||
|
return Message{}, fmt.Errorf("api error: %s", parsed.Error.Message)
|
||||||
|
}
|
||||||
|
if len(parsed.Choices) == 0 {
|
||||||
|
return Message{}, fmt.Errorf("no choices in response: %s", string(raw))
|
||||||
|
}
|
||||||
|
return parsed.Choices[0].Message, nil
|
||||||
|
}
|
||||||
29
safepath.go
Normal file
29
safepath.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveSafe joins p onto root, cleans it, and confirms the result stays
|
||||||
|
// within root. Rejects absolute paths and anything that escapes via "..".
|
||||||
|
func resolveSafe(root, p string) (string, error) {
|
||||||
|
if p == "" {
|
||||||
|
return "", fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(p) {
|
||||||
|
return "", fmt.Errorf("absolute paths are not allowed: %s", p)
|
||||||
|
}
|
||||||
|
joined := filepath.Join(root, p)
|
||||||
|
cleaned := filepath.Clean(joined)
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(root, cleaned)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot resolve %q: %w", p, err)
|
||||||
|
}
|
||||||
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return "", fmt.Errorf("path escapes project root: %s", p)
|
||||||
|
}
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
225
tools.go
Normal file
225
tools.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tool struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function ToolFunction `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolFunction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Parameters map[string]any `json:"parameters"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolDefinitions() []Tool {
|
||||||
|
return []Tool{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunction{
|
||||||
|
Name: "read_file",
|
||||||
|
Description: "Read a UTF-8 text file relative to the project root. Returns the file contents.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Relative path from the project root.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunction{
|
||||||
|
Name: "create_file",
|
||||||
|
Description: "Create a new file with the given content. Fails if the file already exists. Parent directories are created as needed.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{"type": "string", "description": "Relative path from the project root."},
|
||||||
|
"content": map[string]any{"type": "string", "description": "Full file contents to write."},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "content"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunction{
|
||||||
|
Name: "edit_file",
|
||||||
|
Description: "Overwrite an existing file with new content. Fails if the file does not exist.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{"type": "string", "description": "Relative path from the project root."},
|
||||||
|
"content": map[string]any{"type": "string", "description": "Full replacement contents."},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "content"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunction{
|
||||||
|
Name: "delete_file",
|
||||||
|
Description: "Delete a file. Fails if the path is a directory or does not exist.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{"type": "string", "description": "Relative path from the project root."},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunction{
|
||||||
|
Name: "list_directory",
|
||||||
|
Description: "List the entries of a directory, relative to the project root. Use \".\" for the root.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{"type": "string", "description": "Relative directory path. Use \".\" for the project root."},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTool executes a tool call and returns a string payload for the model.
|
||||||
|
// Errors are returned as strings too so the model can react, not fatal.
|
||||||
|
func runTool(root, name, argsJSON string) string {
|
||||||
|
var a struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if argsJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(argsJSON), &a); err != nil {
|
||||||
|
return fmt.Sprintf("error: invalid arguments: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "read_file":
|
||||||
|
return doRead(root, a.Path)
|
||||||
|
case "create_file":
|
||||||
|
return doCreate(root, a.Path, a.Content)
|
||||||
|
case "edit_file":
|
||||||
|
return doEdit(root, a.Path, a.Content)
|
||||||
|
case "delete_file":
|
||||||
|
return doDelete(root, a.Path)
|
||||||
|
case "list_directory":
|
||||||
|
return doList(root, a.Path)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("error: unknown tool %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func doRead(root, p string) string {
|
||||||
|
abs, err := resolveSafe(root, p)
|
||||||
|
if err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func doCreate(root, p, content string) string {
|
||||||
|
abs, err := resolveSafe(root, p)
|
||||||
|
if err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(abs); err == nil {
|
||||||
|
return fmt.Sprintf("error: file already exists: %s", p)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("created %s (%d bytes)", p, len(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func doEdit(root, p, content string) string {
|
||||||
|
abs, err := resolveSafe(root, p)
|
||||||
|
if err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
info, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return fmt.Sprintf("error: path is a directory: %s", p)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("updated %s (%d bytes)", p, len(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func doDelete(root, p string) string {
|
||||||
|
abs, err := resolveSafe(root, p)
|
||||||
|
if err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
if abs == root {
|
||||||
|
return "error: refusing to delete project root"
|
||||||
|
}
|
||||||
|
info, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return fmt.Sprintf("error: path is a directory: %s", p)
|
||||||
|
}
|
||||||
|
if err := os.Remove(abs); err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("deleted %s", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func doList(root, p string) string {
|
||||||
|
if p == "" {
|
||||||
|
p = "."
|
||||||
|
}
|
||||||
|
abs, err := resolveSafe(root, p)
|
||||||
|
if err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "error: " + err.Error()
|
||||||
|
}
|
||||||
|
lines := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if e.IsDir() {
|
||||||
|
name += "/"
|
||||||
|
}
|
||||||
|
lines = append(lines, name)
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return "(empty)"
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user