This commit is contained in:
2026-04-17 11:12:45 +03:00
commit f4b1ac5789
14 changed files with 656 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
.config.json

10
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

41
build.sh Executable file
View 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
View 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
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module NyxTexCodingAgent
go 1.25

112
main.go Normal file
View 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
View 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
View 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
View 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")
}