Skip to content

Commit b414b1e

Browse files
wesmclaude
andauthored
Add roborev prompt and repo commands (#59)
## Summary - Add `roborev prompt` command for ad-hoc AI prompts with `--agent`, `--reasoning`, `--wait`, `--quiet`, and `--no-context` flags - Add `roborev repo` command with subcommands: `list`, `show`, `rename`, `delete`, `merge` for repository management - Add database functions: `EnqueuePromptJob`, `FindRepo`, `DeleteRepo` (with cascade), `MergeRepos`, `GetRepoStats` - Fix prompt job handling: no verdict computation, proper exit codes, TUI display - Fix repo commands to resolve paths from subdirectories to git repo root - Address multiple code review findings (#1125, #1129, #1130, #1131, #1134, #1142) ## Test plan - [x] Run `go test ./...` - all tests pass - [x] Test `roborev prompt "what is 2+2" --wait` - returns result - [x] Test `roborev repo list` - shows repositories - [x] Test `roborev repo show .` from subdirectory - resolves to repo root - [x] Verify prompt jobs show "-" in P/F column (no verdict) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 562eb62 commit b414b1e

File tree

15 files changed

+3320
-72
lines changed

15 files changed

+3320
-72
lines changed

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ roborev tui # View reviews in interactive UI
4848
| `roborev review --branch` | Review all commits on current branch |
4949
| `roborev review --dirty` | Review uncommitted changes |
5050
| `roborev review --reasoning <level>` | Set reasoning depth (thorough/standard/fast) |
51+
| `roborev prompt "<text>"` | Run ad-hoc prompt with AI agent |
5152
| `roborev respond <id> [msg]` | Add a response/note to a review |
5253
| `roborev address <id>` | Mark review as addressed |
5354
| `roborev refine` | Auto-fix failed reviews using AI |
55+
| `roborev repo list` | List tracked repositories |
56+
| `roborev repo rename <old> <new>` | Rename a repository's display name |
5457
| `roborev stream` | Stream review events (JSONL) |
5558
| `roborev daemon start\|stop\|restart` | Manage the daemon |
5659
| `roborev install-hook` | Install git post-commit hook |
@@ -91,6 +94,54 @@ if ! roborev review --dirty --wait --quiet; then
9194
fi
9295
```
9396

97+
## Ad-Hoc Prompts
98+
99+
Use `prompt` to run arbitrary prompts with AI agents for tasks beyond code review:
100+
101+
```bash
102+
roborev prompt "Explain the architecture of this codebase"
103+
roborev prompt --wait "What does the main function do?"
104+
roborev prompt --agent claude-code "Refactor error handling in main.go"
105+
roborev prompt --reasoning thorough "Find potential security issues"
106+
cat instructions.txt | roborev prompt --wait
107+
```
108+
109+
**Flags:**
110+
111+
| Flag | Description |
112+
|------|-------------|
113+
| `--wait` | Wait for job to complete and show result |
114+
| `--agent` | Agent to use (default: from config) |
115+
| `--reasoning` | Reasoning level: fast, standard, or thorough |
116+
| `--no-context` | Don't include repository context in prompt |
117+
| `--quiet` | Suppress output (just enqueue) |
118+
119+
By default, prompts include context about the repository (name, path, and any project guidelines from `.roborev.toml`). Use `--no-context` for raw prompts.
120+
121+
## Repository Management
122+
123+
Manage repositories tracked by roborev:
124+
125+
```bash
126+
roborev repo list # List all repos with review counts
127+
roborev repo show my-project # Show repo details and stats
128+
roborev repo rename old-name new-name # Rename display name
129+
roborev repo delete old-project # Remove from tracking
130+
roborev repo merge source target # Merge reviews into another repo
131+
```
132+
133+
**Subcommands:**
134+
135+
| Command | Description |
136+
|---------|-------------|
137+
| `repo list` | List all repositories with review counts |
138+
| `repo show <name>` | Show detailed stats for a repository |
139+
| `repo rename <old> <new>` | Rename a repository's display name |
140+
| `repo delete <name>` | Remove repository from tracking |
141+
| `repo merge <src> <dst>` | Move all reviews to another repo |
142+
143+
The rename command is useful for grouping reviews after project renames or when you want a friendlier display name than the directory name. The merge command consolidates duplicate entries (e.g., from symlinks or path changes).
144+
94145
## Auto-Fixing Failed Reviews
95146

96147
Use `refine` to automatically address failed reviews on your branch:

cmd/roborev/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ func main() {
6060
rootCmd.AddCommand(streamCmd())
6161
rootCmd.AddCommand(tuiCmd())
6262
rootCmd.AddCommand(refineCmd())
63+
rootCmd.AddCommand(promptCmd())
64+
rootCmd.AddCommand(repoCmd())
6365
rootCmd.AddCommand(skillsCmd())
6466
rootCmd.AddCommand(updateCmd())
6567
rootCmd.AddCommand(versionCmd())

cmd/roborev/prompt.go

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"time"
13+
14+
"github.com/spf13/cobra"
15+
"github.com/wesm/roborev/internal/config"
16+
"github.com/wesm/roborev/internal/git"
17+
"github.com/wesm/roborev/internal/storage"
18+
)
19+
20+
func promptCmd() *cobra.Command {
21+
var (
22+
agentName string
23+
reasoning string
24+
wait bool
25+
quiet bool
26+
noContext bool
27+
)
28+
29+
cmd := &cobra.Command{
30+
Use: "prompt [prompt-text]",
31+
Short: "Execute an ad-hoc prompt with an AI agent",
32+
Long: `Execute an arbitrary prompt using an AI agent.
33+
34+
This command runs a prompt directly with an agent, useful for ad-hoc
35+
work that may not be a traditional code review.
36+
37+
The prompt can be provided as:
38+
1. A positional argument: roborev prompt "your prompt here"
39+
2. Via stdin: echo "your prompt" | roborev prompt
40+
41+
By default, the job is enqueued and the command returns immediately.
42+
Use --wait to wait for completion and display the result.
43+
44+
By default, context about the repository (name, path, and any project
45+
guidelines from .roborev.toml) is included. Use --no-context to disable.
46+
47+
Examples:
48+
roborev prompt "Explain the architecture of this codebase"
49+
roborev prompt --agent claude-code "Refactor the error handling in main.go"
50+
roborev prompt --reasoning thorough "Find potential security issues"
51+
roborev prompt --wait "What does the main function do?"
52+
roborev prompt --no-context "What is 2+2?"
53+
cat instructions.txt | roborev prompt --wait
54+
`,
55+
RunE: func(cmd *cobra.Command, args []string) error {
56+
return runPrompt(cmd, args, agentName, reasoning, wait, quiet, !noContext)
57+
},
58+
}
59+
60+
cmd.Flags().StringVar(&agentName, "agent", "", "agent to use (default: from config)")
61+
cmd.Flags().StringVar(&reasoning, "reasoning", "", "reasoning level: fast, standard, or thorough (default)")
62+
cmd.Flags().BoolVar(&wait, "wait", false, "wait for job to complete and show result")
63+
cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "suppress output (just enqueue)")
64+
cmd.Flags().BoolVar(&noContext, "no-context", false, "don't include repository context in prompt")
65+
66+
return cmd
67+
}
68+
69+
func runPrompt(cmd *cobra.Command, args []string, agentName, reasoningStr string, wait, quiet, includeContext bool) error {
70+
// Get prompt from args or stdin
71+
var promptText string
72+
if len(args) > 0 {
73+
promptText = strings.Join(args, " ")
74+
} else {
75+
// Read from stdin
76+
stat, err := os.Stdin.Stat()
77+
if err != nil {
78+
return fmt.Errorf("unable to read stdin: %w", err)
79+
}
80+
if (stat.Mode() & os.ModeCharDevice) == 0 {
81+
// Stdin has data (piped) - use io.ReadAll to handle large prompts
82+
data, err := io.ReadAll(os.Stdin)
83+
if err != nil {
84+
return fmt.Errorf("reading stdin: %w", err)
85+
}
86+
promptText = string(data)
87+
} else {
88+
return fmt.Errorf("no prompt provided - pass as argument or pipe via stdin")
89+
}
90+
}
91+
92+
if strings.TrimSpace(promptText) == "" {
93+
return fmt.Errorf("empty prompt")
94+
}
95+
96+
// Determine working directory (use git repo root if in a repo, otherwise cwd)
97+
workDir, err := os.Getwd()
98+
if err != nil {
99+
return fmt.Errorf("get working directory: %w", err)
100+
}
101+
102+
// Try to use git repo root if available
103+
repoRoot := workDir
104+
if root, err := git.GetRepoRoot(workDir); err == nil {
105+
repoRoot = root
106+
}
107+
108+
// Build the full prompt with context if enabled
109+
fullPrompt := promptText
110+
if includeContext {
111+
fullPrompt = buildPromptWithContext(repoRoot, promptText)
112+
}
113+
114+
// Ensure daemon is running
115+
if err := ensureDaemon(); err != nil {
116+
return err
117+
}
118+
119+
// Build the request
120+
reqBody, _ := json.Marshal(map[string]interface{}{
121+
"repo_path": repoRoot,
122+
"git_ref": "prompt",
123+
"agent": agentName,
124+
"reasoning": reasoningStr,
125+
"custom_prompt": fullPrompt,
126+
})
127+
128+
resp, err := http.Post(serverAddr+"/api/enqueue", "application/json", bytes.NewReader(reqBody))
129+
if err != nil {
130+
return fmt.Errorf("failed to connect to daemon: %w", err)
131+
}
132+
defer resp.Body.Close()
133+
134+
body, err := io.ReadAll(resp.Body)
135+
if err != nil {
136+
return fmt.Errorf("failed to read response: %w", err)
137+
}
138+
139+
if resp.StatusCode != http.StatusCreated {
140+
return fmt.Errorf("enqueue failed: %s", body)
141+
}
142+
143+
var job storage.ReviewJob
144+
if err := json.Unmarshal(body, &job); err != nil {
145+
return fmt.Errorf("failed to parse response: %w", err)
146+
}
147+
148+
if !quiet {
149+
cmd.Printf("Enqueued prompt job %d (agent: %s)\n", job.ID, job.Agent)
150+
}
151+
152+
// If --wait, poll until job completes and show result
153+
if wait {
154+
return waitForPromptJob(cmd, serverAddr, job.ID, quiet)
155+
}
156+
157+
return nil
158+
}
159+
160+
// promptPollInterval is the initial poll interval for waiting on prompt jobs.
161+
// Can be overridden in tests to speed them up.
162+
var promptPollInterval = 500 * time.Millisecond
163+
164+
// waitForPromptJob waits for a prompt job to complete and displays the result.
165+
// Unlike waitForJob, this doesn't apply verdict-based exit codes since prompt
166+
// jobs don't have PASS/FAIL verdicts.
167+
func waitForPromptJob(cmd *cobra.Command, serverAddr string, jobID int64, quiet bool) error {
168+
client := &http.Client{Timeout: 5 * time.Second}
169+
170+
if !quiet {
171+
cmd.Printf("Waiting for review to complete...")
172+
}
173+
174+
// Poll with exponential backoff
175+
pollInterval := promptPollInterval
176+
maxInterval := 5 * time.Second
177+
unknownStatusCount := 0
178+
const maxUnknownRetries = 10 // Give up after 10 consecutive unknown statuses
179+
180+
for {
181+
resp, err := client.Get(fmt.Sprintf("%s/api/jobs?id=%d", serverAddr, jobID))
182+
if err != nil {
183+
return fmt.Errorf("failed to check job status: %w", err)
184+
}
185+
186+
if resp.StatusCode != http.StatusOK {
187+
body, _ := io.ReadAll(resp.Body)
188+
resp.Body.Close()
189+
return fmt.Errorf("server error checking job status (%d): %s", resp.StatusCode, body)
190+
}
191+
192+
var jobsResp struct {
193+
Jobs []storage.ReviewJob `json:"jobs"`
194+
}
195+
if err := json.NewDecoder(resp.Body).Decode(&jobsResp); err != nil {
196+
resp.Body.Close()
197+
return fmt.Errorf("failed to parse job status: %w", err)
198+
}
199+
resp.Body.Close()
200+
201+
if len(jobsResp.Jobs) == 0 {
202+
return fmt.Errorf("job %d not found", jobID)
203+
}
204+
205+
job := jobsResp.Jobs[0]
206+
207+
switch job.Status {
208+
case storage.JobStatusDone:
209+
// Pass done message to showPromptResult - it prints after successful fetch
210+
return showPromptResult(cmd, serverAddr, jobID, quiet, " done!\n\n")
211+
212+
case storage.JobStatusFailed:
213+
if !quiet {
214+
cmd.Printf(" failed!\n")
215+
}
216+
return fmt.Errorf("prompt failed: %s", job.Error)
217+
218+
case storage.JobStatusCanceled:
219+
if !quiet {
220+
cmd.Printf(" canceled!\n")
221+
}
222+
return fmt.Errorf("prompt was canceled")
223+
224+
case storage.JobStatusQueued, storage.JobStatusRunning:
225+
// Still in progress, continue polling
226+
unknownStatusCount = 0 // Reset counter on known status
227+
time.Sleep(pollInterval)
228+
if pollInterval < maxInterval {
229+
pollInterval = time.Duration(float64(pollInterval) * 1.5)
230+
if pollInterval > maxInterval {
231+
pollInterval = maxInterval
232+
}
233+
}
234+
235+
default:
236+
// Unknown status - treat as transient for forward-compatibility
237+
// (daemon may add new statuses in the future)
238+
unknownStatusCount++
239+
if unknownStatusCount >= maxUnknownRetries {
240+
return fmt.Errorf("received unknown status %q %d times, giving up (daemon may be newer than CLI)", job.Status, unknownStatusCount)
241+
}
242+
if !quiet {
243+
cmd.Printf("\n(unknown status %q, continuing to poll...)", job.Status)
244+
}
245+
time.Sleep(pollInterval)
246+
if pollInterval < maxInterval {
247+
pollInterval = time.Duration(float64(pollInterval) * 1.5)
248+
if pollInterval > maxInterval {
249+
pollInterval = maxInterval
250+
}
251+
}
252+
}
253+
}
254+
}
255+
256+
// showPromptResult fetches and displays the result of a prompt job.
257+
// Unlike showReview, this doesn't apply verdict-based exit codes.
258+
// The doneMsg parameter is printed before the result on success (used for "done!" message).
259+
func showPromptResult(cmd *cobra.Command, addr string, jobID int64, quiet bool, doneMsg string) error {
260+
client := &http.Client{Timeout: 5 * time.Second}
261+
resp, err := client.Get(fmt.Sprintf("%s/api/review?job_id=%d", addr, jobID))
262+
if err != nil {
263+
return fmt.Errorf("failed to fetch result: %w", err)
264+
}
265+
defer resp.Body.Close()
266+
267+
if resp.StatusCode == http.StatusNotFound {
268+
return fmt.Errorf("no result found for job %d", jobID)
269+
}
270+
if resp.StatusCode != http.StatusOK {
271+
body, _ := io.ReadAll(resp.Body)
272+
return fmt.Errorf("server error fetching result (%d): %s", resp.StatusCode, body)
273+
}
274+
275+
var review storage.Review
276+
if err := json.NewDecoder(resp.Body).Decode(&review); err != nil {
277+
return fmt.Errorf("failed to parse result: %w", err)
278+
}
279+
280+
// Only print after successful fetch to avoid "done!" followed by error
281+
if !quiet {
282+
if doneMsg != "" {
283+
cmd.Print(doneMsg)
284+
}
285+
cmd.Printf("Result (by %s)\n", review.Agent)
286+
cmd.Println(strings.Repeat("-", 60))
287+
cmd.Println(review.Output)
288+
}
289+
290+
// Prompt jobs always exit 0 on success (no verdict-based exit codes)
291+
return nil
292+
}
293+
294+
// buildPromptWithContext wraps the user's prompt with repository context
295+
func buildPromptWithContext(repoPath, userPrompt string) string {
296+
var sb strings.Builder
297+
298+
repoName := filepath.Base(repoPath)
299+
300+
sb.WriteString("## Context\n\n")
301+
sb.WriteString(fmt.Sprintf("You are working in the repository \"%s\" at %s.\n", repoName, repoPath))
302+
303+
// Load project guidelines if available
304+
repoCfg, err := config.LoadRepoConfig(repoPath)
305+
if err == nil && repoCfg != nil && repoCfg.ReviewGuidelines != "" {
306+
sb.WriteString("\n## Project Guidelines\n\n")
307+
sb.WriteString("The following are project-specific guidelines for this repository:\n\n")
308+
sb.WriteString(strings.TrimSpace(repoCfg.ReviewGuidelines))
309+
sb.WriteString("\n")
310+
}
311+
312+
sb.WriteString("\n## Request\n\n")
313+
sb.WriteString(userPrompt)
314+
315+
return sb.String()
316+
}

0 commit comments

Comments
 (0)