Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ devtap install --adapter claude-code

This configures the MCP server and injects devtap instructions into your project's instruction file (e.g., `CLAUDE.md`). See [Supported Tools](#supported-tools) for all available adapters.

### Skills (Optional)

devtap ships with a built-in [skill](https://docs.anthropic.com/en/docs/claude-code/skills) that works as a CLI fallback when MCP is unavailable. Install it to let the agent fetch build output on demand:

```bash
# Claude Code
mkdir -p ~/.claude/skills && cp -r skills/devtap-get-build-errors ~/.claude/skills/

# Codex CLI
mkdir -p ~/.codex/skills && cp -r skills/devtap-get-build-errors ~/.codex/skills/
```

Or fetch directly from the repo without cloning:

```bash
DEST=~/.claude/skills/devtap-get-build-errors
mkdir -p "$DEST" && cd "$DEST"
for f in SKILL.md scripts/get_build_errors.sh; do
mkdir -p "$(dirname "$f")"
curl -sL "https://raw.githubusercontent.com/killme2008/devtap/main/skills/devtap-get-build-errors/$f" -o "$f"
done
chmod +x scripts/get_build_errors.sh
```

### Usage

**Terminal A** — capture build output:
Expand Down Expand Up @@ -299,7 +323,9 @@ Subcommands:
install Configure AI tool integration (--session and --store are forwarded to MCP config)
mcp-serve Start MCP stdio server
drain Read pending messages as plain text
-q, --quiet Raw output without source/tag headers
status Show pending message counts
-q, --quiet Compact output (pending count only)
history Query build error history (GreptimeDB only)
--since <dur> Time range (default "24h")
--tag <label> Filter by tag
Expand Down
16 changes: 13 additions & 3 deletions cmd/devtap/drain.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ For MCP-capable tools, use "devtap mcp-serve" instead.`,
cmd.Flags().Int("max-retries", 5, "max retries for auto-loop")
cmd.Flags().Int("max-lines", 10000, "max lines to drain")
cmd.Flags().String("filter-sql", "", "SQL WHERE clause for GreptimeDB filtering")
cmd.Flags().BoolP("quiet", "q", false, "raw output without source headers")

return cmd
}
Expand All @@ -39,14 +40,15 @@ func runDrain(cmd *cobra.Command, args []string) error {
maxRetries, _ := cmd.Flags().GetInt("max-retries")
maxLines, _ := cmd.Flags().GetInt("max-lines")
filterSQL, _ := cmd.Flags().GetString("filter-sql")
quiet, _ := cmd.Flags().GetBool("quiet")

if maxLines <= 0 {
maxLines = 10000
}

// If --filter-sql is set, fall back to single-source drain against the configured store.
if filterSQL != "" {
return runDrainSingleSource(cmd, filterSQL, maxLines, event, autoLoop, maxRetries)
return runDrainSingleSource(cmd, filterSQL, maxLines, event, autoLoop, maxRetries, quiet)
}

sources, cleanup, err := resolveDrainSources(cmd)
Expand Down Expand Up @@ -136,6 +138,10 @@ func runDrain(cmd *cobra.Command, args []string) error {
}

// Plain text output
if quiet {
fmt.Println(mcp.FormatMessagesRaw(allMessages))
return nil
}
if multiSource {
var sourceLabels []string
for _, src := range sources {
Expand All @@ -148,7 +154,7 @@ func runDrain(cmd *cobra.Command, args []string) error {
}

// runDrainSingleSource handles drain with --filter-sql which only works with a single GreptimeDB store.
func runDrainSingleSource(cmd *cobra.Command, filterSQL string, maxLines int, event string, autoLoop bool, maxRetries int) error {
func runDrainSingleSource(cmd *cobra.Command, filterSQL string, maxLines int, event string, autoLoop bool, maxRetries int, quiet bool) error {
adapterName, _ := cmd.Flags().GetString("adapter")
sessionFlag, _ := cmd.Flags().GetString("session")

Expand Down Expand Up @@ -194,7 +200,11 @@ func runDrainSingleSource(cmd *cobra.Command, filterSQL string, maxLines int, ev
return nil
}

fmt.Println(mcp.FormatMessages(messages))
if quiet {
fmt.Println(mcp.FormatMessagesRaw(messages))
} else {
fmt.Println(mcp.FormatMessages(messages))
}
return nil
}

Expand Down
23 changes: 22 additions & 1 deletion cmd/devtap/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ import (

"github.com/spf13/cobra"

"github.com/killme2008/devtap/internal/store"
greptimestore "github.com/killme2008/devtap/internal/store/greptimedb"
)

func statusCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "status",
Short: "Show pending message counts per session",
SilenceUsage: true,
RunE: runStatus,
}
cmd.Flags().BoolP("quiet", "q", false, "compact output (pending count only)")
return cmd
}

func runStatus(cmd *cobra.Command, args []string) error {
Expand All @@ -28,6 +31,11 @@ func runStatus(cmd *cobra.Command, args []string) error {
if adapter == "" {
adapter = "claude-code"
}
quiet, _ := cmd.Flags().GetBool("quiet")

if quiet {
return runStatusQuiet(s)
}

// Try extended status for GreptimeDB
if gs, ok := s.(*greptimestore.Store); ok {
Expand All @@ -52,6 +60,19 @@ func runStatus(cmd *cobra.Command, args []string) error {
return nil
}

func runStatusQuiet(s store.Store) error {
counts, err := s.Status()
if err != nil {
return fmt.Errorf("get status: %w", err)
}
total := 0
for _, count := range counts {
total += count
}
fmt.Println(total)
return nil
}

func showExtendedStatus(gs *greptimestore.Store, adapter string) error {
// Show pending counts first
counts, err := gs.Status()
Expand Down
52 changes: 52 additions & 0 deletions cmd/devtap/storefactory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"testing"

"github.com/killme2008/devtap/internal/config"
)

func TestOpenStoreStrictFileBackend(t *testing.T) {
dir := t.TempDir()
cfg := config.Default()

s, err := openStoreStrict(cfg, "file", dir, "claude-code")
if err != nil {
t.Fatalf("openStoreStrict(file): %v", err)
}
_ = s.Close()
}

func TestOpenStoreStrictEmptyBackend(t *testing.T) {
dir := t.TempDir()
cfg := config.Default()

s, err := openStoreStrict(cfg, "", dir, "claude-code")
if err != nil {
t.Fatalf("openStoreStrict(''): %v", err)
}
_ = s.Close()
}

func TestOpenStoreStrictUnknownBackend(t *testing.T) {
dir := t.TempDir()
cfg := config.Default()

_, err := openStoreStrict(cfg, "redis", dir, "claude-code")
if err == nil {
t.Fatal("expected error for unknown backend")
}
}

func TestOpenStoreStrictGreptimeDBNoFallback(t *testing.T) {
dir := t.TempDir()
cfg := config.Default()
// Point to an unreachable endpoint so New or Ping fails.
cfg.Store.GreptimeDB.Endpoint = "127.0.0.1:19999"
cfg.Store.GreptimeDB.MySQLEndpoint = "127.0.0.1:19998"

_, err := openStoreStrict(cfg, "greptimedb", dir, "claude-code")
if err == nil {
t.Fatal("expected error when greptimedb is unreachable, should not fall back to file store")
}
}
21 changes: 21 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,27 @@ func (s *Server) writeJSON(v any) {
_, _ = fmt.Fprintf(s.output, "%s\n", data)
}

// FormatMessagesRaw outputs raw lines without [devtap: tag] headers.
// Messages are separated by blank lines.
func FormatMessagesRaw(messages []store.LogMessage) string {
var sb strings.Builder
wroteAny := false
for _, msg := range messages {
if len(msg.Lines) == 0 {
continue
}
if wroteAny {
sb.WriteString("\n")
}
for _, line := range msg.Lines {
sb.WriteString(line)
sb.WriteString("\n")
}
wroteAny = true
}
return strings.TrimRight(sb.String(), "\n")
}

// FormatMessages converts log messages into a human-readable string.
func FormatMessages(messages []store.LogMessage) string {
var sb strings.Builder
Expand Down
44 changes: 44 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,50 @@ func TestFormatMessagesNoExitCode(t *testing.T) {
}
}

func TestFormatMessagesRaw(t *testing.T) {
exitCode := 1
messages := []store.LogMessage{
{Tag: "mypy", Stream: "stderr", Lines: []string{"main.py:5: error: Incompatible types"}, ExitCode: &exitCode},
{Tag: "go-build", Stream: "stdout", Lines: []string{"ok", "all good"}},
}

text := FormatMessagesRaw(messages)

// Should contain raw lines without [devtap: tag] headers
if strings.Contains(text, "[devtap:") {
t.Error("raw output should not contain [devtap:] headers")
}
if !strings.Contains(text, "Incompatible types") {
t.Error("should contain error line")
}
if !strings.Contains(text, "all good") {
t.Error("should contain all lines")
}
// Messages separated by blank line
if !strings.Contains(text, "Incompatible types\n\nok") {
t.Errorf("messages should be separated by blank line, got:\n%s", text)
}
}

func TestFormatMessagesRawEmpty(t *testing.T) {
text := FormatMessagesRaw(nil)
if text != "" {
t.Errorf("empty input should produce empty output, got: %q", text)
}
}

func TestFormatMessagesRawSkipsEmptyLines(t *testing.T) {
messages := []store.LogMessage{
{Tag: "a", Lines: []string{}},
{Tag: "b", Lines: []string{"hello"}},
}

text := FormatMessagesRaw(messages)
if text != "hello" {
t.Errorf("expected %q, got %q", "hello", text)
}
}

// --- Multi-source tests ---

// errStore implements store.Store and returns configurable errors on Drain/Status.
Expand Down
28 changes: 28 additions & 0 deletions skills/devtap-get-build-errors/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
name: devtap-get-build-errors
description: Fetch pending devtap build/dev output for the current turn using get_build_status and get_build_errors, then present the captured output verbatim. Use when the user asks to check build errors, latest build logs, or "/get_build_errors"-style actions.
metadata:
short-description: Fetch devtap output
---

# devtap-get-build-errors

Use this skill when the user asks for build errors, latest build output, or an equivalent quick action.

## Workflow

1. Call `get_build_status` once.
2. Call `get_build_errors` once when any of these is true:
- status reports pending output
- user explicitly asks to fetch/check logs now
- user says new build/test/dev output arrived
3. Present `get_build_errors` content verbatim in a fenced code block.
4. Keep source warnings verbatim (for example, unreachable source warnings).
5. After the block, add one line: `Next action: <what you will do>`.

## Rules

- Do not summarize or rewrite build output.
- Do not call `get_build_errors` repeatedly in the same turn unless new output is reported.
- MCP tool names map to CLI subcommands: `get_build_status` → `devtap status`, `get_build_errors` → `devtap drain`.
- If MCP tools are unavailable, use `scripts/get_build_errors.sh` as CLI fallback.
14 changes: 14 additions & 0 deletions skills/devtap-get-build-errors/scripts/get_build_errors.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env sh
set -eu

MAX_LINES="${1:-10000}"

if ! command -v devtap >/dev/null 2>&1; then
echo "devtap not found in PATH" >&2
exit 127
fi

pending_count=$(devtap status --quiet 2>/dev/null || echo 0)
if [ "$pending_count" -gt 0 ] 2>/dev/null; then
devtap drain --max-lines "$MAX_LINES"
fi