Skip to content

Commit 257a1c2

Browse files
committed
fix: forgot cmd
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
1 parent cd26659 commit 257a1c2

File tree

16 files changed

+1438
-1
lines changed

16 files changed

+1438
-1
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Build output
2-
devtap
2+
/devtap
33
dist/
44

55
# devtap adapter configs

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,19 @@ devtap --tag cargo-test --debounce 5s -- cargo watch -x test
174174
2. Project marker files (nearest parent with one of: `go.mod`, `package.json`, `pyproject.toml`, `Cargo.toml`, `pom.xml`, `build.gradle`, `build.gradle.kts`, `composer.json`, `Gemfile`, `setup.py`)
175175
3. Current working directory
176176

177+
**Chaining commands**`devtap` captures a single command after `--`. If you need shell operators like `&&`, wrap the command in a shell:
178+
179+
```bash
180+
devtap -- sh -c "npm install && npm run dev"
181+
```
182+
183+
Or run them separately:
184+
185+
```bash
186+
devtap -- npm install
187+
devtap -- npm run dev
188+
```
189+
177190
## CLI Reference
178191

179192
```

cmd/devtap/drain.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/killme2008/devtap/internal/mcp"
10+
"github.com/killme2008/devtap/internal/store"
11+
greptimestore "github.com/killme2008/devtap/internal/store/greptimedb"
12+
)
13+
14+
func drainCmd() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "drain",
17+
Short: "Consume pending messages and output plain text",
18+
Long: `Read pending build output and print it as plain text.
19+
20+
Used by lint wrapper scripts (aider) and the Stop hook (auto-loop).
21+
For MCP-capable tools, use "devtap mcp-serve" instead.`,
22+
SilenceUsage: true,
23+
RunE: runDrain,
24+
}
25+
26+
cmd.Flags().String("event", "", "hook event name (only needed for Stop auto-loop)")
27+
cmd.Flags().Bool("auto-loop", false, "enable auto-loop mode (block Stop if errors pending)")
28+
cmd.Flags().Int("max-retries", 5, "max retries for auto-loop")
29+
cmd.Flags().Int("max-lines", 10000, "max lines to drain")
30+
cmd.Flags().String("filter-sql", "", "SQL WHERE clause for GreptimeDB filtering")
31+
32+
return cmd
33+
}
34+
35+
func runDrain(cmd *cobra.Command, args []string) error {
36+
adapterName, _ := cmd.Flags().GetString("adapter")
37+
sessionFlag, _ := cmd.Flags().GetString("session")
38+
event, _ := cmd.Flags().GetString("event")
39+
autoLoop, _ := cmd.Flags().GetBool("auto-loop")
40+
maxRetries, _ := cmd.Flags().GetInt("max-retries")
41+
maxLines, _ := cmd.Flags().GetInt("max-lines")
42+
filterSQL, _ := cmd.Flags().GetString("filter-sql")
43+
44+
sessionID, err := resolveSession(adapterName, sessionFlag)
45+
if err != nil {
46+
return fmt.Errorf("resolve session: %w", err)
47+
}
48+
49+
s, err := openStore(cmd)
50+
if err != nil {
51+
return fmt.Errorf("create store: %w", err)
52+
}
53+
defer func() { _ = s.Close() }()
54+
55+
// Drain messages (with SQL filter if using GreptimeDB)
56+
var messages []store.LogMessage
57+
if filterSQL != "" {
58+
if gs, ok := s.(*greptimestore.Store); ok {
59+
messages, err = gs.DrainSQL(sessionID, filterSQL, maxLines)
60+
} else {
61+
return fmt.Errorf("--filter-sql requires --store greptimedb")
62+
}
63+
} else {
64+
messages, err = s.Drain(sessionID, maxLines)
65+
}
66+
if err != nil {
67+
return fmt.Errorf("drain: %w", err)
68+
}
69+
70+
messages = mcp.TruncateMessages(messages, maxLines)
71+
72+
// Handle auto-loop Stop hook (Claude Code specific)
73+
if event == "Stop" && autoLoop {
74+
storeDir, err := defaultStoreDir()
75+
if err != nil {
76+
return fmt.Errorf("resolve store dir: %w", err)
77+
}
78+
return handleAutoLoopStop(storeDir, sessionID, messages, maxRetries)
79+
}
80+
81+
// Reset retry counter on any non-Stop drain (e.g., user submitted a new prompt).
82+
if event != "" && event != "Stop" {
83+
if storeDir, err := defaultStoreDir(); err == nil {
84+
tracker := store.NewRetryTracker(storeDir)
85+
_ = tracker.Reset(sessionID)
86+
}
87+
}
88+
89+
if len(messages) == 0 {
90+
return nil
91+
}
92+
93+
// Plain text output
94+
fmt.Println(mcp.FormatMessages(messages))
95+
return nil
96+
}
97+
98+
func handleAutoLoopStop(storeDir, sessionID string, messages []store.LogMessage, maxRetries int) error {
99+
hasErrors := false
100+
for _, msg := range messages {
101+
if msg.ExitCode != nil && *msg.ExitCode != 0 {
102+
hasErrors = true
103+
break
104+
}
105+
}
106+
107+
if !hasErrors || len(messages) == 0 {
108+
// No errors — allow Claude to stop
109+
fmt.Print("{}")
110+
return nil
111+
}
112+
113+
tracker := store.NewRetryTracker(storeDir)
114+
count, err := tracker.Get(sessionID)
115+
if err != nil {
116+
return fmt.Errorf("get retry count: %w", err)
117+
}
118+
119+
if count >= maxRetries {
120+
// Exceeded retry limit — allow stop
121+
fmt.Print("{}")
122+
return nil
123+
}
124+
125+
newCount, err := tracker.Increment(sessionID)
126+
if err != nil {
127+
return fmt.Errorf("increment retry count: %w", err)
128+
}
129+
130+
// Annotate tags with attempt info
131+
for i := range messages {
132+
tag := messages[i].Tag
133+
if tag == "" {
134+
tag = "build"
135+
}
136+
messages[i].Tag = fmt.Sprintf("%s (attempt %d/%d)", tag, newCount, maxRetries)
137+
}
138+
139+
reason := mcp.FormatMessages(messages)
140+
output, err := json.Marshal(map[string]any{
141+
"decision": "block",
142+
"reason": reason,
143+
})
144+
if err != nil {
145+
return fmt.Errorf("marshal stop response: %w", err)
146+
}
147+
148+
fmt.Print(string(output))
149+
return nil
150+
}

cmd/devtap/drain_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/killme2008/devtap/internal/mcp"
9+
"github.com/killme2008/devtap/internal/store"
10+
)
11+
12+
func TestTruncateMessagesNoTruncation(t *testing.T) {
13+
messages := []store.LogMessage{
14+
{Tag: "a", Lines: []string{"line1", "line2"}},
15+
{Tag: "b", Lines: []string{"line3"}},
16+
}
17+
result := mcp.TruncateMessages(messages, 10)
18+
if len(result) != 2 {
19+
t.Fatalf("expected 2 messages, got %d", len(result))
20+
}
21+
if len(result[0].Lines) != 2 || len(result[1].Lines) != 1 {
22+
t.Error("lines should be unchanged when under limit")
23+
}
24+
}
25+
26+
func TestTruncateMessagesTruncates(t *testing.T) {
27+
// Create 2 messages with 60 lines each = 120 total, truncate to 20.
28+
lines1 := make([]string, 60)
29+
lines2 := make([]string, 60)
30+
for i := range lines1 {
31+
lines1[i] = "msg1-line"
32+
lines2[i] = "msg2-line"
33+
}
34+
35+
messages := []store.LogMessage{
36+
{Tag: "a", Lines: lines1},
37+
{Tag: "b", Lines: lines2},
38+
}
39+
result := mcp.TruncateMessages(messages, 20)
40+
41+
// Both messages should be present with truncated lines.
42+
totalLines := 0
43+
for _, msg := range result {
44+
totalLines += len(msg.Lines)
45+
}
46+
if totalLines > 20 {
47+
t.Errorf("total lines %d should not exceed max 20", totalLines)
48+
}
49+
50+
// Both messages should have lines (proportional allocation).
51+
if len(result[0].Lines) == 0 {
52+
t.Error("first message should have lines after truncation")
53+
}
54+
if len(result[1].Lines) == 0 {
55+
t.Error("second message should have lines after truncation")
56+
}
57+
58+
// Tags should be preserved.
59+
if result[0].Tag != "a" || result[1].Tag != "b" {
60+
t.Error("tags should be preserved")
61+
}
62+
}
63+
64+
func TestTruncateMessagesPreservesEmptyLines(t *testing.T) {
65+
messages := []store.LogMessage{
66+
{Tag: "a", Lines: []string{"line1"}},
67+
{Tag: "b"},
68+
{Tag: "c", Lines: []string{"line2"}},
69+
}
70+
result := mcp.TruncateMessages(messages, 10)
71+
if len(result) != 3 {
72+
t.Fatalf("expected 3 messages, got %d", len(result))
73+
}
74+
if result[1].Lines != nil {
75+
t.Error("empty message should remain empty")
76+
}
77+
}
78+
79+
func TestTruncateMessagesZeroMaxLines(t *testing.T) {
80+
messages := []store.LogMessage{
81+
{Tag: "a", Lines: []string{"line1"}},
82+
}
83+
result := mcp.TruncateMessages(messages, 0)
84+
if len(result) != 1 || len(result[0].Lines) != 1 {
85+
t.Error("maxLines=0 should skip truncation")
86+
}
87+
}
88+
89+
func TestHandleAutoLoopStopNoErrors(t *testing.T) {
90+
dir := t.TempDir()
91+
exitCode := 0
92+
messages := []store.LogMessage{
93+
{Tag: "build", ExitCode: &exitCode},
94+
}
95+
96+
// Capture stdout
97+
old := os.Stdout
98+
r, w, _ := os.Pipe()
99+
os.Stdout = w
100+
101+
err := handleAutoLoopStop(dir, "test-session", messages, 5)
102+
103+
_ = w.Close()
104+
os.Stdout = old
105+
106+
if err != nil {
107+
t.Fatalf("handleAutoLoopStop: %v", err)
108+
}
109+
110+
buf := make([]byte, 1024)
111+
n, _ := r.Read(buf)
112+
output := string(buf[:n])
113+
if output != "{}" {
114+
t.Errorf("expected '{}' for no errors, got %q", output)
115+
}
116+
}
117+
118+
func TestHandleAutoLoopStopWithErrors(t *testing.T) {
119+
dir := t.TempDir()
120+
_ = os.MkdirAll(filepath.Join(dir, "test-session"), 0o755)
121+
122+
exitCode := 1
123+
messages := []store.LogMessage{
124+
{Tag: "build", Lines: []string{"error: something failed"}, ExitCode: &exitCode},
125+
}
126+
127+
old := os.Stdout
128+
r, w, _ := os.Pipe()
129+
os.Stdout = w
130+
131+
err := handleAutoLoopStop(dir, "test-session", messages, 5)
132+
133+
_ = w.Close()
134+
os.Stdout = old
135+
136+
if err != nil {
137+
t.Fatalf("handleAutoLoopStop: %v", err)
138+
}
139+
140+
buf := make([]byte, 4096)
141+
n, _ := r.Read(buf)
142+
output := string(buf[:n])
143+
144+
if output == "{}" {
145+
t.Error("should block when errors present")
146+
}
147+
// Should contain "block" decision
148+
if !contains(output, "block") {
149+
t.Errorf("output should contain 'block' decision, got %q", output)
150+
}
151+
}
152+
153+
func TestHandleAutoLoopStopExceedsRetries(t *testing.T) {
154+
dir := t.TempDir()
155+
sessionID := "test-session"
156+
_ = os.MkdirAll(filepath.Join(dir, sessionID), 0o755)
157+
158+
exitCode := 1
159+
messages := []store.LogMessage{
160+
{Tag: "build", Lines: []string{"error"}, ExitCode: &exitCode},
161+
}
162+
163+
// Exhaust retries.
164+
maxRetries := 2
165+
for i := 0; i < maxRetries; i++ {
166+
old := os.Stdout
167+
_, w, _ := os.Pipe()
168+
os.Stdout = w
169+
_ = handleAutoLoopStop(dir, sessionID, messages, maxRetries)
170+
_ = w.Close()
171+
os.Stdout = old
172+
}
173+
174+
// Next call should allow stop (return "{}").
175+
old := os.Stdout
176+
r, w, _ := os.Pipe()
177+
os.Stdout = w
178+
179+
err := handleAutoLoopStop(dir, sessionID, messages, maxRetries)
180+
181+
_ = w.Close()
182+
os.Stdout = old
183+
184+
if err != nil {
185+
t.Fatalf("handleAutoLoopStop: %v", err)
186+
}
187+
188+
buf := make([]byte, 1024)
189+
n, _ := r.Read(buf)
190+
output := string(buf[:n])
191+
if output != "{}" {
192+
t.Errorf("expected '{}' after exhausting retries, got %q", output)
193+
}
194+
}
195+
196+
func contains(s, substr string) bool {
197+
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr))
198+
}
199+
200+
func containsSubstr(s, substr string) bool {
201+
for i := 0; i <= len(s)-len(substr); i++ {
202+
if s[i:i+len(substr)] == substr {
203+
return true
204+
}
205+
}
206+
return false
207+
}

0 commit comments

Comments
 (0)