Skip to content

Commit b2bab41

Browse files
shahar-cauraclaude
andcommitted
forge: Add natural language command classification
Route unrecognized CLI input through Claude to classify intent and map it to the appropriate forge subcommand. Includes recursion guard, dynamic context injection (plan files + run IDs), and comprehensive tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2f2f3dd commit b2bab41

8 files changed

Lines changed: 568 additions & 0 deletions

File tree

cmd/forge/cmd_nl.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"log/slog"
7+
"os"
8+
"strings"
9+
10+
"github.com/shahar-caura/forge/internal/intent"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func runNaturalLanguage(cmd *cobra.Command, logger *slog.Logger, args []string) error {
15+
if len(args) == 0 {
16+
return cmd.Help()
17+
}
18+
19+
// Recursion guard: prevent classify → execute → classify loops.
20+
if os.Getenv("FORGE_NL_CLASSIFIED") == "1" {
21+
return fmt.Errorf("unknown command %q", args[0])
22+
}
23+
24+
query := strings.Join(args, " ")
25+
logger.Info("classifying natural language input", "query", query)
26+
27+
result, err := intent.Classify(cmd.Context(), query)
28+
if err != nil {
29+
if errors.Is(err, intent.ErrNoClaude) {
30+
return fmt.Errorf("unknown command %q (install claude CLI to enable natural language mode)", args[0])
31+
}
32+
return fmt.Errorf("could not interpret %q as a forge command: %w", query, err)
33+
}
34+
35+
// Validate that the resolved subcommand actually exists.
36+
sub := result.Argv[0]
37+
found := false
38+
for _, c := range cmd.Root().Commands() {
39+
if c.Name() == sub {
40+
found = true
41+
break
42+
}
43+
}
44+
if !found {
45+
return fmt.Errorf("could not interpret %q as a forge command (resolved to unknown subcommand %q)", query, sub)
46+
}
47+
48+
fmt.Fprintf(os.Stderr, "=> forge %s\n", strings.Join(result.Argv, " "))
49+
50+
os.Setenv("FORGE_NL_CLASSIFIED", "1")
51+
defer os.Unsetenv("FORGE_NL_CLASSIFIED")
52+
53+
cmd.Root().SetArgs(result.Argv)
54+
return cmd.Root().Execute()
55+
}

internal/intent/classify.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package intent
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os/exec"
8+
"strings"
9+
"time"
10+
)
11+
12+
// CommandContext is the function used to create exec.Cmd. Override in tests.
13+
var CommandContext = exec.CommandContext
14+
15+
// Classify interprets a natural language query as a forge command.
16+
func Classify(ctx context.Context, query string) (*Result, error) {
17+
if _, err := exec.LookPath("claude"); err != nil {
18+
return nil, ErrNoClaude
19+
}
20+
21+
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
22+
defer cancel()
23+
24+
dc := GatherContext()
25+
prompt := BuildPrompt(query, dc)
26+
27+
cmd := CommandContext(ctx, "claude", "-p", prompt, "--output-format", "json", "--max-tokens", "256")
28+
out, err := cmd.CombinedOutput()
29+
if err != nil {
30+
if ctx.Err() == context.DeadlineExceeded {
31+
return nil, fmt.Errorf("%w: timed out after 30s", ErrClassificationFailed)
32+
}
33+
return nil, fmt.Errorf("%w: %s", ErrClassificationFailed, string(out))
34+
}
35+
36+
return parseResponse(string(out))
37+
}
38+
39+
// parseResponse extracts a Result from the claude CLI JSON output.
40+
// Handles the JSON envelope ({"result":"..."}) and strips accidental code fences.
41+
func parseResponse(raw string) (*Result, error) {
42+
// First, try to unwrap claude's JSON envelope.
43+
text := extractResultField(raw)
44+
45+
// Strip accidental code fences (```json ... ```).
46+
text = stripCodeFences(text)
47+
text = strings.TrimSpace(text)
48+
49+
var r Result
50+
if err := json.Unmarshal([]byte(text), &r); err != nil {
51+
return nil, fmt.Errorf("%w: invalid JSON: %s", ErrClassificationFailed, err)
52+
}
53+
54+
if len(r.Argv) == 0 {
55+
return nil, fmt.Errorf("%w: empty argv", ErrClassificationFailed)
56+
}
57+
58+
return &r, nil
59+
}
60+
61+
// extractResultField unwraps claude's {"result":"..."} envelope.
62+
// Falls back to the raw string if parsing fails.
63+
func extractResultField(raw string) string {
64+
var envelope struct {
65+
Result string `json:"result"`
66+
}
67+
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
68+
return raw
69+
}
70+
if envelope.Result == "" {
71+
return raw
72+
}
73+
return envelope.Result
74+
}
75+
76+
// stripCodeFences removes markdown code fences wrapping JSON.
77+
func stripCodeFences(s string) string {
78+
s = strings.TrimSpace(s)
79+
if strings.HasPrefix(s, "```") {
80+
// Remove opening fence line.
81+
if idx := strings.Index(s, "\n"); idx != -1 {
82+
s = s[idx+1:]
83+
}
84+
// Remove closing fence.
85+
if idx := strings.LastIndex(s, "```"); idx != -1 {
86+
s = s[:idx]
87+
}
88+
}
89+
return strings.TrimSpace(s)
90+
}

internal/intent/classify_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package intent
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"os/exec"
8+
"testing"
9+
)
10+
11+
// testHelper is a test binary approach: when invoked as a subprocess, it
12+
// writes the value of TEST_CLAUDE_OUTPUT to stdout and exits with TEST_CLAUDE_EXIT.
13+
func TestHelperProcess(t *testing.T) {
14+
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
15+
return
16+
}
17+
out := os.Getenv("TEST_CLAUDE_OUTPUT")
18+
_, _ = os.Stdout.WriteString(out)
19+
if os.Getenv("TEST_CLAUDE_EXIT") == "1" {
20+
os.Exit(1)
21+
}
22+
os.Exit(0)
23+
}
24+
25+
func fakeCommandContext(output string, exitErr bool) func(ctx context.Context, name string, args ...string) *exec.Cmd {
26+
return func(ctx context.Context, name string, args ...string) *exec.Cmd {
27+
cs := []string{"-test.run=TestHelperProcess", "--"}
28+
cs = append(cs, args...)
29+
cmd := exec.CommandContext(ctx, os.Args[0], cs...)
30+
cmd.Env = append(os.Environ(),
31+
"GO_WANT_HELPER_PROCESS=1",
32+
"TEST_CLAUDE_OUTPUT="+output,
33+
)
34+
if exitErr {
35+
cmd.Env = append(cmd.Env, "TEST_CLAUDE_EXIT=1")
36+
}
37+
return cmd
38+
}
39+
}
40+
41+
func TestClassify_Success(t *testing.T) {
42+
// Envelope wrapping actual JSON result.
43+
envelope := `{"result":"{\"argv\":[\"run\",\"plans/auth.md\"],\"confidence\":0.95,\"reasoning\":\"user wants to run auth plan\"}"}`
44+
45+
orig := CommandContext
46+
CommandContext = fakeCommandContext(envelope, false)
47+
defer func() { CommandContext = orig }()
48+
49+
r, err := Classify(context.Background(), "run the auth plan")
50+
if err != nil {
51+
t.Fatalf("unexpected error: %v", err)
52+
}
53+
if len(r.Argv) != 2 || r.Argv[0] != "run" || r.Argv[1] != "plans/auth.md" {
54+
t.Fatalf("unexpected argv: %v", r.Argv)
55+
}
56+
if r.Confidence < 0.9 {
57+
t.Fatalf("unexpected confidence: %f", r.Confidence)
58+
}
59+
}
60+
61+
func TestClassify_NoClaude(t *testing.T) {
62+
// Override PATH so claude is not found.
63+
orig := os.Getenv("PATH")
64+
os.Setenv("PATH", "")
65+
defer os.Setenv("PATH", orig)
66+
67+
_, err := Classify(context.Background(), "anything")
68+
if !errors.Is(err, ErrNoClaude) {
69+
t.Fatalf("expected ErrNoClaude, got: %v", err)
70+
}
71+
}
72+
73+
func TestClassify_ExitError(t *testing.T) {
74+
orig := CommandContext
75+
CommandContext = fakeCommandContext("something went wrong", true)
76+
defer func() { CommandContext = orig }()
77+
78+
_, err := Classify(context.Background(), "do something")
79+
if !errors.Is(err, ErrClassificationFailed) {
80+
t.Fatalf("expected ErrClassificationFailed, got: %v", err)
81+
}
82+
}
83+
84+
func TestClassify_MalformedJSON(t *testing.T) {
85+
orig := CommandContext
86+
CommandContext = fakeCommandContext(`{"result":"not json at all"}`, false)
87+
defer func() { CommandContext = orig }()
88+
89+
_, err := Classify(context.Background(), "do something")
90+
if !errors.Is(err, ErrClassificationFailed) {
91+
t.Fatalf("expected ErrClassificationFailed, got: %v", err)
92+
}
93+
}
94+
95+
func TestClassify_EmptyArgv(t *testing.T) {
96+
orig := CommandContext
97+
CommandContext = fakeCommandContext(`{"result":"{\"argv\":[],\"confidence\":0.1,\"reasoning\":\"unclear\"}"}`, false)
98+
defer func() { CommandContext = orig }()
99+
100+
_, err := Classify(context.Background(), "do something")
101+
if !errors.Is(err, ErrClassificationFailed) {
102+
t.Fatalf("expected ErrClassificationFailed, got: %v", err)
103+
}
104+
}
105+
106+
func TestClassify_CodeFencedJSON(t *testing.T) {
107+
// Claude sometimes wraps JSON in code fences.
108+
fenced := "{\"result\":\"```json\\n{\\\"argv\\\":[\\\"runs\\\"],\\\"confidence\\\":0.9,\\\"reasoning\\\":\\\"list runs\\\"}\\n```\"}"
109+
110+
orig := CommandContext
111+
CommandContext = fakeCommandContext(fenced, false)
112+
defer func() { CommandContext = orig }()
113+
114+
r, err := Classify(context.Background(), "show my runs")
115+
if err != nil {
116+
t.Fatalf("unexpected error: %v", err)
117+
}
118+
if len(r.Argv) != 1 || r.Argv[0] != "runs" {
119+
t.Fatalf("unexpected argv: %v", r.Argv)
120+
}
121+
}
122+
123+
func TestParseResponse_DirectJSON(t *testing.T) {
124+
// No envelope, direct JSON.
125+
r, err := parseResponse(`{"argv":["status","abc123"],"confidence":0.85,"reasoning":"check status"}`)
126+
if err != nil {
127+
t.Fatalf("unexpected error: %v", err)
128+
}
129+
if len(r.Argv) != 2 || r.Argv[0] != "status" {
130+
t.Fatalf("unexpected argv: %v", r.Argv)
131+
}
132+
}
133+
134+
func TestStripCodeFences(t *testing.T) {
135+
tests := []struct {
136+
name string
137+
in string
138+
want string
139+
}{
140+
{"no fences", `{"argv":["runs"]}`, `{"argv":["runs"]}`},
141+
{"with fences", "```json\n{\"argv\":[\"runs\"]}\n```", `{"argv":["runs"]}`},
142+
{"with bare fences", "```\n{\"argv\":[\"runs\"]}\n```", `{"argv":["runs"]}`},
143+
}
144+
for _, tt := range tests {
145+
t.Run(tt.name, func(t *testing.T) {
146+
got := stripCodeFences(tt.in)
147+
if got != tt.want {
148+
t.Fatalf("stripCodeFences(%q) = %q, want %q", tt.in, got, tt.want)
149+
}
150+
})
151+
}
152+
}

internal/intent/context.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package intent
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/shahar-caura/forge/internal/state"
9+
)
10+
11+
// DynamicContext holds runtime information injected into the classification prompt.
12+
type DynamicContext struct {
13+
PlanFiles []string
14+
RunIDs []string
15+
}
16+
17+
// GatherContext collects plan files and recent run IDs for prompt injection.
18+
func GatherContext() DynamicContext {
19+
var dc DynamicContext
20+
21+
plans, _ := filepath.Glob("plans/*.md")
22+
for _, p := range plans {
23+
dc.PlanFiles = append(dc.PlanFiles, filepath.Base(p))
24+
}
25+
26+
runs, err := state.List()
27+
if err == nil {
28+
cap := min(10, len(runs))
29+
for _, r := range runs[:cap] {
30+
dc.RunIDs = append(dc.RunIDs, r.ID)
31+
}
32+
}
33+
34+
return dc
35+
}
36+
37+
// FormatForPrompt renders dynamic context as markdown for inclusion in the prompt.
38+
func FormatForPrompt(dc DynamicContext) string {
39+
var sb strings.Builder
40+
41+
if len(dc.PlanFiles) > 0 {
42+
sb.WriteString("## Available plan files\n")
43+
for _, f := range dc.PlanFiles {
44+
fmt.Fprintf(&sb, "- plans/%s\n", f)
45+
}
46+
sb.WriteString("\n")
47+
}
48+
49+
if len(dc.RunIDs) > 0 {
50+
sb.WriteString("## Recent run IDs\n")
51+
for _, id := range dc.RunIDs {
52+
fmt.Fprintf(&sb, "- %s\n", id)
53+
}
54+
sb.WriteString("\n")
55+
}
56+
57+
return sb.String()
58+
}

0 commit comments

Comments
 (0)