summaryrefslogtreecommitdiffstats
path: root/main.go
diff options
context:
space:
mode:
authorBx <bx@boxstep.io>2026-08-25 12:48:12 -0700
committerBx <bx@boxstep.io>2026-08-25 12:48:12 -0700
commit50ca864530f5672528cd90032cdc3089f347a9bb (patch)
treed74100954e3d952f17f55c9a1ad1b526255708b2 /main.go
downloadpico-50ca864530f5672528cd90032cdc3089f347a9bb.tar.gz
pico-50ca864530f5672528cd90032cdc3089f347a9bb.zip
initial commit
Diffstat (limited to 'main.go')
-rw-r--r--main.go611
1 files changed, 611 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..df82ac9
--- /dev/null
+++ b/main.go
@@ -0,0 +1,611 @@
1// pico — a minimal single-binary CLI coding agent.
2package main
3
4import (
5 "bufio"
6 "bytes"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "regexp"
15 "sort"
16 "strconv"
17 "strings"
18 "syscall"
19 "time"
20 "unicode/utf8"
21)
22
23// ------------------------------------------------------------------- palette
24
25const (
26 reset = "\x1b[0m"
27 bold = "\x1b[1m"
28 dim = "\x1b[2m"
29 blue = "\x1b[34m"
30 cyan = "\x1b[36m"
31 green = "\x1b[32m"
32 red = "\x1b[31m"
33)
34
35// -------------------------------------------------------------------- config
36
37var (
38 apiURL = firstEnv("API_URL", "ANTHROPIC_BASE_URL", "http://localhost:8000")
39 apiKey = firstEnv("API_KEY", "ANTHROPIC_API_KEY", "")
40 model = firstEnv("MODEL", "ANTHROPIC_MODEL", "gpt-oss-120b")
41)
42
43func firstEnv(keys ...string) string {
44 def := keys[len(keys)-1]
45 for _, k := range keys[:len(keys)-1] {
46 if k == "" {
47 continue
48 }
49 if v := os.Getenv(k); v != "" {
50 return strings.TrimRight(v, "/")
51 }
52 }
53 return def
54}
55
56// --------------------------------------------------------------------- tools
57
58type param struct {
59 name string
60 typ string // trailing "?" marks optional
61}
62
63type tool struct {
64 name string
65 desc string
66 params []param
67 run func(in map[string]any) (string, error)
68}
69
70var tools = []tool{
71 {"read", "Read file with line numbers (file path, not directory)",
72 []param{{"path", "string"}, {"offset", "integer?"}, {"limit", "integer?"}}, toolRead},
73 {"write", "Write content to file",
74 []param{{"path", "string"}, {"content", "string"}}, toolWrite},
75 {"edit", "Replace old with new in file (old must be unique unless all=true)",
76 []param{{"path", "string"}, {"old", "string"}, {"new", "string"}, {"all", "boolean?"}}, toolEdit},
77 {"glob", "Find files by pattern, sorted by mtime",
78 []param{{"pat", "string"}, {"path", "string?"}}, toolGlob},
79 {"grep", "Search files for regex pattern",
80 []param{{"pat", "string"}, {"path", "string?"}}, toolGrep},
81 {"bash", "Run shell command",
82 []param{{"cmd", "string"}}, toolBash},
83}
84
85var byName = func() map[string]*tool {
86 m := map[string]*tool{}
87 for i := range tools {
88 m[tools[i].name] = &tools[i]
89 }
90 return m
91}()
92
93// schemas is built once at startup and reused verbatim on every request, so
94// the serialized prefix stays byte-identical and KV cache holds.
95// Properties are emitted in declaration order, which map marshaling would not
96// preserve. Each property carries "type" and nothing else.
97var schemas = func() []json.RawMessage {
98 out := make([]json.RawMessage, len(tools))
99 for i, t := range tools {
100 var props, required []string
101 for _, p := range t.params {
102 props = append(props, fmt.Sprintf(`%s:{"type":%s}`, jstr(p.name), jstr(strings.TrimSuffix(p.typ, "?"))))
103 if !strings.HasSuffix(p.typ, "?") {
104 required = append(required, jstr(p.name))
105 }
106 }
107 out[i] = json.RawMessage(fmt.Sprintf(
108 `{"name":%s,"description":%s,"input_schema":{"type":"object","properties":{%s},"required":[%s]}}`,
109 jstr(t.name), jstr(t.desc), strings.Join(props, ","), strings.Join(required, ",")))
110 }
111 return out
112}()
113
114func jstr(s string) string {
115 enc, _ := json.Marshal(s)
116 return string(enc)
117}
118
119// ------------------------------------------------------------- arg coercion
120
121func argStr(in map[string]any, k, def string) string {
122 if s, ok := in[k].(string); ok {
123 return s
124 }
125 return def
126}
127
128func argInt(in map[string]any, k string, def int) int {
129 switch v := in[k].(type) {
130 case float64:
131 return int(v)
132 case string:
133 if n, err := strconv.Atoi(v); err == nil {
134 return n
135 }
136 }
137 return def
138}
139
140func argBool(in map[string]any, k string, def bool) bool {
141 if b, ok := in[k].(bool); ok {
142 return b
143 }
144 return def
145}
146
147// ---------------------------------------------------------------------- read
148
149func toolRead(in map[string]any) (string, error) {
150 data, err := os.ReadFile(argStr(in, "path", ""))
151 if err != nil {
152 return "", err
153 }
154 // SplitAfter keeps the newline on each line; drop the empty tail element.
155 lines := strings.SplitAfter(string(data), "\n")
156 if lines[len(lines)-1] == "" {
157 lines = lines[:len(lines)-1]
158 }
159 offset := argInt(in, "offset", 0)
160 limit := argInt(in, "limit", len(lines))
161 start := min(max(offset, 0), len(lines))
162 end := min(max(offset+limit, start), len(lines))
163
164 var b strings.Builder
165 for i, ln := range lines[start:end] {
166 fmt.Fprintf(&b, "%4d| %s", start+i+1, ln)
167 }
168 return b.String(), nil
169}
170
171// ---------------------------------------------------------------- write, edit
172
173func toolWrite(in map[string]any) (string, error) {
174 if err := os.WriteFile(argStr(in, "path", ""), []byte(argStr(in, "content", "")), 0644); err != nil {
175 return "", err
176 }
177 return "ok", nil
178}
179
180func toolEdit(in map[string]any) (string, error) {
181 path := argStr(in, "path", "")
182 data, err := os.ReadFile(path)
183 if err != nil {
184 return "", err
185 }
186 s, old, new := string(data), argStr(in, "old", ""), argStr(in, "new", "")
187
188 // Both of these are ordinary results, not errors: the model reads them
189 // and retries with a longer `old`.
190 switch n := strings.Count(s, old); {
191 case n == 0:
192 return "error: old_string not found", nil
193 case n > 1 && !argBool(in, "all", false):
194 return fmt.Sprintf("error: old_string appears %d times, must be unique (use all=true)", n), nil
195 }
196
197 if argBool(in, "all", false) {
198 s = strings.ReplaceAll(s, old, new)
199 } else {
200 s = strings.Replace(s, old, new, 1)
201 }
202 if err := os.WriteFile(path, []byte(s), 0644); err != nil {
203 return "", err
204 }
205 return "ok", nil
206}
207
208// ---------------------------------------------------------------- glob, grep
209
210func argPath(in map[string]any) string {
211 if p := argStr(in, "path", "."); p != "" {
212 return p
213 }
214 return "."
215}
216
217func toolGlob(in map[string]any) (string, error) {
218 matches := globExpand(strings.ReplaceAll(argPath(in)+"/"+argStr(in, "pat", ""), "//", "/"))
219 if len(matches) == 0 {
220 return "none", nil
221 }
222 mtime := make(map[string]int64, len(matches))
223 for _, m := range matches {
224 if fi, err := os.Stat(m); err == nil && !fi.IsDir() {
225 mtime[m] = fi.ModTime().UnixNano()
226 }
227 }
228 sort.SliceStable(matches, func(i, j int) bool { return mtime[matches[i]] > mtime[matches[j]] })
229 return strings.Join(matches, "\n"), nil
230}
231
232func toolGrep(in map[string]any) (string, error) {
233 re, err := regexp.Compile(argStr(in, "pat", ""))
234 if err != nil {
235 return "", err
236 }
237 var hits []string
238 for _, p := range globExpand(argPath(in) + "/**") {
239 f, err := os.Open(p)
240 if err != nil {
241 continue
242 }
243 // Directories appear in the ** expansion; scanning one yields nothing.
244 sc := bufio.NewScanner(f)
245 sc.Buffer(make([]byte, 0, 64*1024), 8<<20)
246 for n := 1; sc.Scan(); n++ {
247 if line := sc.Text(); re.MatchString(line) {
248 hits = append(hits, fmt.Sprintf("%s:%d:%s", p, n, strings.TrimRight(line, " \t\r")))
249 }
250 }
251 f.Close()
252 if len(hits) >= 50 {
253 break
254 }
255 }
256 if len(hits) == 0 {
257 return "none", nil
258 }
259 if len(hits) > 50 {
260 hits = hits[:50]
261 }
262 return strings.Join(hits, "\n"), nil
263}
264
265// ---------------------------------------------------------------------- bash
266
267func toolBash(in map[string]any) (string, error) {
268 pr, pw, err := os.Pipe()
269 if err != nil {
270 return "", err
271 }
272 cmd := exec.Command("sh", "-c", argStr(in, "cmd", ""))
273 cmd.Stdout, cmd.Stderr = pw, pw // one pipe: interleaved in real order
274 // Own process group, so the timeout below can kill the whole tree.
275 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
276 if err := cmd.Start(); err != nil {
277 pw.Close()
278 pr.Close()
279 return "", err
280 }
281 // The parent MUST drop its own copy of the write end, or the pipe never
282 // reaches EOF and the reader below blocks forever -- even for `true`.
283 pw.Close()
284
285 done := make(chan string, 1)
286 go func() {
287 defer pr.Close()
288 var acc strings.Builder
289 sc := bufio.NewScanner(pr)
290 sc.Buffer(make([]byte, 0, 64*1024), 8<<20)
291 for sc.Scan() {
292 line := sc.Text()
293 fmt.Printf(" %s│ %s%s\n", dim, strings.TrimRight(line, " \t\r"), reset)
294 acc.WriteString(line + "\n")
295 }
296 done <- acc.String()
297 }()
298
299 var out string
300 select {
301 case out = <-done:
302 case <-time.After(30 * time.Second):
303 // Kill the GROUP, not just sh: `sh -c "sleep 60"` forks, and the
304 // grandchild inherits the pipe's write end. Killing sh alone leaves
305 // the pipe open and the drain below blocks until the grandchild
306 // exits on its own -- a 30s timeout that takes 60s.
307 if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
308 cmd.Process.Kill()
309 }
310 out = <-done // let the reader drain before reporting
311 out += "\n(timed out after 30s)"
312 }
313 cmd.Wait() // always reap
314
315 if out = strings.TrimSpace(out); out == "" {
316 return "(empty)", nil
317 }
318 return out, nil
319}
320
321// ------------------------------------------------------------------ dispatch
322
323func runTool(name string, input json.RawMessage) string {
324 t, ok := byName[name]
325 if !ok {
326 return fmt.Sprintf("error: unknown tool %q", name)
327 }
328 var in map[string]any
329 json.Unmarshal(input, &in)
330 out, err := t.run(in)
331 if err != nil {
332 return "error: " + err.Error()
333 }
334 return out
335}
336
337// --------------------------------------------------------------- glob engine
338
339func globExpand(pattern string) []string {
340 root, rest := ".", pattern
341 if strings.HasPrefix(pattern, "/") {
342 root, rest = "/", pattern[1:]
343 }
344 var out []string
345 globWalk(root, strings.Split(rest, "/"), &out)
346 if root == "." {
347 for i := range out {
348 out[i] = strings.TrimPrefix(out[i], "./")
349 }
350 }
351 return out
352}
353
354func globWalk(dir string, segs []string, out *[]string) {
355 if len(segs) == 0 {
356 *out = append(*out, dir)
357 return
358 }
359 seg, rest := segs[0], segs[1:]
360
361 if seg == "" || seg == "." {
362 globWalk(dir, rest, out)
363 return
364 }
365
366 if seg == "**" {
367 globWalk(dir, rest, out) // ** matches zero path segments
368 for _, e := range readDir(dir) {
369 if strings.HasPrefix(e.Name(), ".") {
370 continue
371 }
372 switch p := join(dir, e.Name()); {
373 case e.IsDir():
374 globWalk(p, segs, out) // keep ** at the head
375 case len(rest) == 0:
376 *out = append(*out, p)
377 }
378 }
379 return
380 }
381
382 for _, e := range readDir(dir) {
383 name := e.Name()
384 if strings.HasPrefix(name, ".") && !strings.HasPrefix(seg, ".") {
385 continue
386 }
387 if ok, _ := filepath.Match(seg, name); !ok {
388 continue
389 }
390 p := join(dir, name)
391 if len(rest) == 0 {
392 *out = append(*out, p) // file or directory
393 } else if e.IsDir() {
394 globWalk(p, rest, out)
395 }
396 }
397}
398
399// readDir returns entries sorted by name; unreadable directories yield none.
400func readDir(dir string) []os.DirEntry {
401 es, err := os.ReadDir(dir)
402 if err != nil {
403 return nil
404 }
405 return es
406}
407
408func join(dir, name string) string {
409 if strings.HasSuffix(dir, "/") {
410 return dir + name
411 }
412 return dir + "/" + name
413}
414
415// ------------------------------------------------------------------ API call
416
417type message struct {
418 Role string `json:"role"`
419 Content any `json:"content"`
420}
421
422type toolResult struct {
423 Type string `json:"type"`
424 ToolUseID string `json:"tool_use_id"`
425 Content string `json:"content"`
426}
427
428type block struct {
429 Type string `json:"type"`
430 Text string `json:"text"`
431 ID string `json:"id"`
432 Name string `json:"name"`
433 Input json.RawMessage `json:"input"`
434}
435
436var systemPrompt = func() string {
437 cwd, _ := os.Getwd()
438 return "Concise coding assistant. cwd: " + cwd
439}()
440
441func callAPI(messages []message) ([]json.RawMessage, error) {
442 body, err := json.Marshal(map[string]any{
443 "model": model,
444 "max_tokens": 8192,
445 "system": systemPrompt,
446 "messages": messages,
447 "tools": schemas,
448 })
449 if err != nil {
450 return nil, err
451 }
452 req, err := http.NewRequest("POST", apiURL+"/v1/messages", bytes.NewReader(body))
453 if err != nil {
454 return nil, err
455 }
456 // Every header is byte-identical across turns: a varying one would
457 // invalidate llama-swap's KV cache on every request.
458 req.Header.Set("Content-Type", "application/json")
459 req.Header.Set("anthropic-version", "2023-06-01")
460 req.Header.Set("x-api-key", apiKey)
461
462 // No timeout: a cold llama-swap model load costs ~10-25s before prefill
463 // even begins, and llama-swap's own responseHeader timeout is 1440s.
464 resp, err := http.DefaultClient.Do(req)
465 if err != nil {
466 return nil, err
467 }
468 defer resp.Body.Close()
469 raw, err := io.ReadAll(resp.Body)
470 if err != nil {
471 return nil, err
472 }
473 if resp.StatusCode >= 400 {
474 return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(raw), 500))
475 }
476 // Content blocks stay raw: thinking/text/tool_use and anything unknown
477 // all round-trip verbatim into the next request.
478 var parsed struct {
479 Content []json.RawMessage `json:"content"`
480 }
481 if err := json.Unmarshal(raw, &parsed); err != nil {
482 return nil, err
483 }
484 return parsed.Content, nil
485}
486
487// ---------------------------------------------------------------- agent loop
488
489func agentLoop(messages *[]message) error {
490 for {
491 blocks, err := callAPI(*messages)
492 if err != nil {
493 return err
494 }
495 var results []toolResult
496 for _, raw := range blocks {
497 var b block
498 if err := json.Unmarshal(raw, &b); err != nil {
499 return err
500 }
501 switch b.Type {
502 case "text":
503 fmt.Printf("\n%s⏺%s %s\n", cyan, reset, renderMarkdown(b.Text))
504 case "tool_use":
505 fmt.Printf("\n%s⏺ %s%s(%s%s%s)\n",
506 green, capitalize(b.Name), reset, dim, argPreview(b.Name, b.Input), reset)
507 out := runTool(b.Name, b.Input)
508 printResult(out)
509 results = append(results, toolResult{"tool_result", b.ID, out})
510 }
511 // Other block types (thinking) are not displayed but still echoed.
512 }
513 *messages = append(*messages, message{"assistant", blocks})
514 if len(results) == 0 {
515 return nil
516 }
517 *messages = append(*messages, message{"user", results})
518 }
519}
520
521// ------------------------------------------------------------------- display
522
523var boldRe = regexp.MustCompile(`(?s)\*\*(.*?)\*\*`)
524
525func renderMarkdown(s string) string { return boldRe.ReplaceAllString(s, bold+"$1"+reset) }
526
527func capitalize(s string) string {
528 if s == "" {
529 return s
530 }
531 return strings.ToUpper(s[:1]) + s[1:]
532}
533
534// truncate cuts to n code points, never bytes, and never adds an ellipsis.
535func truncate(s string, n int) string {
536 if utf8.RuneCountInString(s) <= n {
537 return s
538 }
539 return string([]rune(s)[:n])
540}
541
542func argPreview(name string, input json.RawMessage) string {
543 t, ok := byName[name]
544 if !ok {
545 return ""
546 }
547 var in map[string]any
548 json.Unmarshal(input, &in)
549 for _, p := range t.params {
550 v, present := in[p.name]
551 if !present {
552 continue
553 }
554 if s, isStr := v.(string); isStr {
555 return truncate(s, 50)
556 }
557 enc, _ := json.Marshal(v)
558 return truncate(string(enc), 50)
559 }
560 return ""
561}
562
563func printResult(out string) {
564 lines := strings.Split(out, "\n")
565 preview := truncate(lines[0], 60)
566 if len(lines) > 1 {
567 preview += fmt.Sprintf(" ... +%d lines", len(lines)-1)
568 } else if utf8.RuneCountInString(lines[0]) > 60 {
569 preview += "..."
570 }
571 fmt.Printf(" %s⎿ %s%s\n", dim, preview, reset)
572}
573
574// ---------------------------------------------------------------------- REPL
575
576func main() {
577 cwd, _ := os.Getwd()
578 fmt.Printf("%spico%s | %s%s | %s%s\n\n", bold, reset, dim, model, cwd, reset)
579
580 sep := dim + strings.Repeat("─", 80) + reset
581 stdin := bufio.NewScanner(os.Stdin)
582 stdin.Buffer(make([]byte, 0, 64*1024), 8<<20)
583
584 var messages []message
585 for {
586 fmt.Println(sep)
587 fmt.Printf("%s%s❯%s ", bold, blue, reset)
588 if !stdin.Scan() {
589 fmt.Println()
590 return
591 }
592 fmt.Println(sep)
593
594 switch line := strings.TrimSpace(stdin.Text()); line {
595 case "":
596 continue
597 case "/q", "exit":
598 return
599 case "/c":
600 messages = nil
601 fmt.Printf("%s⏺ Cleared conversation%s\n", green, reset)
602 default:
603 messages = append(messages, message{"user", line})
604 if err := agentLoop(&messages); err != nil {
605 fmt.Printf("%s⏺ Error: %v%s\n", red, err, reset)
606 continue
607 }
608 fmt.Println()
609 }
610 }
611}