Skip to content

Commit 480a362

Browse files
committed
feat: file attachments via --ctx flag and @ref resolution in CLI
Adds two ways to inject file content into agent prompts: 1. --ctx / -c flag: odek run --ctx main.go,lib.go 'analyze these files' odek run -c data.csv 'what does this contain?' 2. @ reference resolution (inline, works like Web UI): odek run '@src/main.go what does this do?' odek continue '@session-id continue from here' Both work in odek run, odek continue, and the REPL. --ctx errors on missing files (fail-fast). @refs that can't be resolved are left as-is in the prompt (graceful fallback). 7 new tests covering: no-refs passthrough, @ref resolution, --ctx injection, both combined, missing ctx file error, flag parsing.
1 parent 52b567f commit 480a362

4 files changed

Lines changed: 214 additions & 0 deletions

File tree

cmd/odek/main.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ type runFlags struct {
169169
Session *bool // nil = not set; true = save session after run
170170
Learn *bool // nil = not set; true = enable skills learning mode
171171
Task string
172+
Ctx []string // --ctx files to attach
172173

173174
// Sandbox-specific CLI flags
174175
SandboxImage string // Docker image (e.g. "node:20-alpine")
@@ -242,6 +243,9 @@ func parseRunFlags(args []string) (runFlags, error) {
242243
case "--sandbox-user":
243244
f.SandboxUser = args[i+1]
244245
i += 2
246+
case "--ctx", "-c":
247+
f.Ctx = strings.Split(args[i+1], ",")
248+
i += 2
245249
default:
246250
// Not a flag — treat remaining as the task
247251
goto done
@@ -687,6 +691,14 @@ func run(args []string) error {
687691
SandboxUser: f.SandboxUser,
688692
})
689693

694+
// Resolve @references and --ctx file attachments in the task
695+
cwd, _ := os.Getwd()
696+
enriched, err := enrichTask(f.Task, f.Ctx, cwd)
697+
if err != nil {
698+
return err
699+
}
700+
f.Task = enriched
701+
690702
// Determine system message: CLI/project/env override, or default
691703
systemMessage := resolved.System
692704
if systemMessage == "" {
@@ -1362,6 +1374,13 @@ func continueCmd(args []string) error {
13621374
}
13631375
task := strings.Join(args[i:], " ")
13641376

1377+
// Resolve @references in the continue task
1378+
cwd, _ := os.Getwd()
1379+
enriched, err := enrichTask(task, nil, cwd)
1380+
if err == nil {
1381+
task = enriched
1382+
}
1383+
13651384
store, err := session.NewStore()
13661385
if err != nil {
13671386
return fmt.Errorf("session store: %w", err)

cmd/odek/refs.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/BackendStack21/kode/internal/resource"
10+
)
11+
12+
// enrichTask resolves @references in the task prompt and prepends
13+
// --ctx file attachments. Returns the enriched prompt ready for
14+
// the LLM, or an error if any --ctx file can't be read.
15+
//
16+
// @refs that fail to resolve are left as-is in the text.
17+
//
18+
// Examples:
19+
//
20+
// odek run "@main.go what does this do?"
21+
// → resolves @main.go to file content, replaces inline
22+
//
23+
// odek run --ctx main.go "analyze this"
24+
// → prepends file content as context block
25+
//
26+
// odek run --ctx lib.go,util.go "@main.go compare these"
27+
// → both ctx files + @ref resolution
28+
func enrichTask(task string, ctxFiles []string, cwd string) (string, error) {
29+
reg := resource.NewRegistry(resource.NewFileResolver(cwd))
30+
31+
// Step 1: Resolve @ references in the task
32+
enriched := task
33+
refs := resource.ParseRefs(task)
34+
if len(refs) > 0 {
35+
resolved := make(map[string]string)
36+
for _, ref := range refs {
37+
content, err := reg.Load(context.Background(), ref.Raw)
38+
if err != nil {
39+
// Leave unresolved refs as-is
40+
continue
41+
}
42+
resolved[ref.Raw] = content
43+
}
44+
enriched = resource.ReplaceRefs(task, resolved)
45+
}
46+
47+
// Step 2: Add --ctx files as preamble
48+
if len(ctxFiles) > 0 {
49+
var blocks []string
50+
for _, f := range ctxFiles {
51+
f = strings.TrimSpace(f)
52+
if f == "" {
53+
continue
54+
}
55+
content, err := reg.Load(context.Background(), "@"+f)
56+
if err != nil {
57+
return "", fmt.Errorf("ctx file %q: %w", f, err)
58+
}
59+
blocks = append(blocks, fmt.Sprintf("--- %s ---\n%s\n--- end %s ---", f, content, f))
60+
}
61+
if len(blocks) > 0 {
62+
// Log attached files to stderr
63+
fmt.Fprintf(os.Stderr, "odek: attached %d file(s)\n", len(blocks))
64+
enriched = strings.Join(blocks, "\n\n") + "\n\n" + enriched
65+
}
66+
}
67+
68+
return enriched, nil
69+
}

cmd/odek/refs_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestEnrichTask_NoRefsOrCtx(t *testing.T) {
11+
result, err := enrichTask("hello world", nil, "/tmp")
12+
if err != nil {
13+
t.Fatalf("enrichTask error: %v", err)
14+
}
15+
if result != "hello world" {
16+
t.Errorf("expected unchanged task, got %q", result)
17+
}
18+
}
19+
20+
func TestEnrichTask_WithAtRef(t *testing.T) {
21+
dir := t.TempDir()
22+
src := filepath.Join(dir, "hello.txt")
23+
os.WriteFile(src, []byte("Hello, World!"), 0644)
24+
25+
result, err := enrichTask("@hello.txt what does this say?", nil, dir)
26+
if err != nil {
27+
t.Fatalf("enrichTask error: %v", err)
28+
}
29+
30+
if !strings.Contains(result, "Hello, World!") {
31+
t.Errorf("expected file content in result, got %q", result)
32+
}
33+
if !strings.Contains(result, "@hello.txt") {
34+
t.Errorf("expected @reference in result header, got %q", result)
35+
}
36+
}
37+
38+
func TestEnrichTask_WithCtxFile(t *testing.T) {
39+
dir := t.TempDir()
40+
src := filepath.Join(dir, "data.txt")
41+
os.WriteFile(src, []byte("file content"), 0644)
42+
43+
result, err := enrichTask("analyze this", []string{"data.txt"}, dir)
44+
if err != nil {
45+
t.Fatalf("enrichTask error: %v", err)
46+
}
47+
48+
if !strings.Contains(result, "file content") {
49+
t.Errorf("expected ctx file content in result, got %q", result)
50+
}
51+
if !strings.HasPrefix(result, "--- data.txt ---") {
52+
t.Errorf("expected ctx block header at start, got %q", result)
53+
}
54+
if !strings.Contains(result, "analyze this") {
55+
t.Errorf("expected task at end, got %q", result)
56+
}
57+
}
58+
59+
func TestEnrichTask_CtxFileNotFound(t *testing.T) {
60+
_, err := enrichTask("analyze this", []string{"nonexistent.txt"}, t.TempDir())
61+
if err == nil {
62+
t.Fatal("expected error for nonexistent ctx file")
63+
}
64+
}
65+
66+
func TestEnrichTask_WithBothCtxAndAtRef(t *testing.T) {
67+
dir := t.TempDir()
68+
os.WriteFile(filepath.Join(dir, "main.txt"), []byte("main content"), 0644)
69+
os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("lib content"), 0644)
70+
71+
result, err := enrichTask("@lib.txt compare with ctx", []string{"main.txt"}, dir)
72+
if err != nil {
73+
t.Fatalf("enrichTask error: %v", err)
74+
}
75+
76+
if !strings.Contains(result, "main content") {
77+
t.Errorf("expected ctx file content: %q", result)
78+
}
79+
if !strings.Contains(result, "lib content") {
80+
t.Errorf("expected @ref file content: %q", result)
81+
}
82+
}
83+
84+
func TestParseRunFlags_CtxFlag(t *testing.T) {
85+
f, err := parseRunFlags([]string{
86+
"--ctx", "main.go,lib.go",
87+
"analyze these files",
88+
})
89+
if err != nil {
90+
t.Fatalf("parseRunFlags error: %v", err)
91+
}
92+
if len(f.Ctx) != 2 {
93+
t.Fatalf("expected 2 ctx files, got %d: %v", len(f.Ctx), f.Ctx)
94+
}
95+
if f.Ctx[0] != "main.go" {
96+
t.Errorf("Ctx[0] = %q, want %q", f.Ctx[0], "main.go")
97+
}
98+
if f.Ctx[1] != "lib.go" {
99+
t.Errorf("Ctx[1] = %q, want %q", f.Ctx[1], "lib.go")
100+
}
101+
if f.Task != "analyze these files" {
102+
t.Errorf("Task = %q, want %q", f.Task, "analyze these files")
103+
}
104+
}
105+
106+
func TestParseRunFlags_CtxShortFlag(t *testing.T) {
107+
f, err := parseRunFlags([]string{
108+
"-c", "data.csv",
109+
"analyze",
110+
})
111+
if err != nil {
112+
t.Fatalf("parseRunFlags error: %v", err)
113+
}
114+
if len(f.Ctx) != 1 || f.Ctx[0] != "data.csv" {
115+
t.Errorf("Ctx = %v, want [data.csv]", f.Ctx)
116+
}
117+
if f.Task != "analyze" {
118+
t.Errorf("Task = %q, want %q", f.Task, "analyze")
119+
}
120+
}

cmd/odek/repl.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,12 @@ func replCmd(args []string) error {
205205
continue
206206
}
207207

208+
// Resolve @references in REPL input
209+
cwd, _ := os.Getwd()
210+
if enriched, err := enrichTask(input, nil, cwd); err == nil {
211+
input = enriched
212+
}
213+
208214
// Build message history: session messages + new user input
209215
messages := sess.GetMessages()
210216
origLen := len(messages)

0 commit comments

Comments
 (0)