commit f4b1ac578916292ed32c5c0d05c399d060cc0030 Author: RDarius Date: Fri Apr 17 11:12:45 2026 +0300 stash diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..261c375 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist/ +.config.json diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -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/ diff --git a/.idea/NyxTexCodingAgent.iml b/.idea/NyxTexCodingAgent.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/NyxTexCodingAgent.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..01f910e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/NyxTexCodingAgent b/NyxTexCodingAgent new file mode 100755 index 0000000..dded536 Binary files /dev/null and b/NyxTexCodingAgent differ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..cf7eb13 --- /dev/null +++ b/build.sh @@ -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." diff --git a/config.go b/config.go new file mode 100644 index 0000000..b1a158f --- /dev/null +++ b/config.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6fd5e43 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module NyxTexCodingAgent + +go 1.25 diff --git a/main.go b/main.go new file mode 100644 index 0000000..37796bd --- /dev/null +++ b/main.go @@ -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 +} diff --git a/openai.go b/openai.go new file mode 100644 index 0000000..4c9c34d --- /dev/null +++ b/openai.go @@ -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 +} diff --git a/safepath.go b/safepath.go new file mode 100644 index 0000000..10f2f9d --- /dev/null +++ b/safepath.go @@ -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 +} diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..3d15dd3 --- /dev/null +++ b/tools.go @@ -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") +}