Files
2026-07-13 13:00:08 +08:00

199 lines
6.4 KiB
Go

package cli
import (
"context"
"flag"
"fmt"
"os"
"strings"
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/sandbox"
"reasonix/internal/skill"
"reasonix/internal/tool"
"reasonix/internal/tool/builtin"
)
func reviewCommand(args []string) int {
fs := flag.NewFlagSet("review", flag.ContinueOnError)
base := fs.String("base", "", "base branch/commit to diff against (defaults to HEAD — reviews uncommitted working-tree changes)")
commit := fs.String("commit", "", "review a specific commit (shows changes introduced by that commit)")
model := fs.String("model", "", "provider name override (default: config default_model)")
instructions := fs.String("instructions", "", "extra review instructions appended to the prompt")
if err := fs.Parse(args); err != nil {
return 2
}
// 1. Get the diff.
diff, err := getReviewDiff(*base, *commit)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if diff == "" {
fmt.Println("No changes to review.")
return 0
}
// 2. Load config and resolve model.
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to load config:", err)
return 1
}
modelName := *model
if modelName == "" {
modelName = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(modelName)
if !ok {
fmt.Fprintf(os.Stderr, "error: unknown model %q — check your config\n", modelName)
return 1
}
if err := cfg.Validate(modelName); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
// 3. Create provider.
prov, err := boot.NewProviderWithProxy(entry, cfg.NetworkProxySpec())
if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to create provider:", err)
return 1
}
// 4. Get the built-in review skill.
root, _ := os.Getwd()
skillStore := skill.New(skill.Options{ProjectRoot: root, Stderr: os.Stderr})
reviewSk, ok := skillStore.Read("review")
if !ok {
fmt.Fprintln(os.Stderr, "error: built-in review skill not found")
return 1
}
if reviewSk.RunAs != skill.RunSubagent {
fmt.Fprintln(os.Stderr, "error: review skill is not a subagent skill")
return 1
}
// 5. Build a review-scoped sub-agent registry.
reg := buildReviewSubagentRegistry(reviewSk, cfg, root)
// 6. Prepare the review prompt.
task := buildReviewTask(diff, *instructions)
// 7. Run the review subagent.
ctx := context.Background()
// Deliberately minimal Options: this one-shot CLI path has no gate, no
// compaction, and no session, unlike the in-session sub-agent paths built
// through TaskTool.subagentOptions / boot's subagentSkillOptions. If a new
// Options field becomes load-bearing for sub-agents, decide explicitly
// whether this path needs it too.
result, err := agent.RunSubAgentWithSession(ctx, prov, reg, agent.NewSession(reviewSk.Body), task, agent.Options{
MaxSteps: 12,
Temperature: cfg.Agent.Temperature,
Pricing: entry.Price,
ContextWindow: entry.ContextWindow,
}, event.Discard)
if err != nil {
fmt.Fprintln(os.Stderr, "error: review failed:", err)
return 1
}
fmt.Print(result)
return 0
}
func buildReviewSubagentRegistry(reviewSk skill.Skill, cfg *config.Config, root string) *tool.Registry {
// The shared helper strips subagent-unavailable background capabilities while
// preserving foreground bash. This direct CLI path does not go through boot,
// so it first builds the small parent set from the review skill allow-list.
parentReg := tool.NewRegistry()
for _, name := range reviewSk.AllowedTools {
if tl, ok := tool.LookupBuiltin(name); ok {
parentReg.Add(tl)
}
}
// Replace the unconfined init-time defaults with confined instances,
// mirroring boot's addBuiltins: readers/search bound to the configured
// forbid-read roots, bash to the OS sandbox spec plus the session-data
// guard. The zero-value tools registered at init honor none of the user's
// [sandbox] config, so `reasonix review` previously read forbid_read
// paths a normal session would refuse.
writeRoots := cfg.WriteRootsForRoot(root)
forbidReadRoots := cfg.ForbidReadRootsForRoot(root)
guard := builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots())
bashSpec := sandbox.Spec{
Mode: cfg.BashMode(),
WriteRoots: writeRoots,
ForbidReadRoots: forbidReadRoots,
Network: cfg.Sandbox.Network,
}
searchSpec := builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, os.Stderr)
confined := append(builtin.ConfineReaders(forbidReadRoots),
builtin.ConfineBash(bashSpec, guard),
builtin.ConfineSearch(searchSpec, bashSpec, forbidReadRoots))
for _, tl := range confined {
if _, ok := parentReg.Get(tl.Name()); ok {
parentReg.Add(tl)
}
}
if reviewSk.ReadOnly {
// The built-in review skill declares read-only; enforce it here exactly
// like the in-session runner does (writer tools stripped, bash under the
// plan-mode safe policy) so `reasonix review` is not a writable backdoor.
return agent.ReadOnlySubagentToolRegistry(parentReg, reviewSk.AllowedTools)
}
return agent.SubagentToolRegistry(parentReg, reviewSk.AllowedTools)
}
// getReviewDiff runs the appropriate git diff command and returns its output.
// - commit="abc": shows diff of abc^..abc
// - base="main": shows diff of main...HEAD
// - neither: shows diff of uncommitted working-tree changes
func getReviewDiff(base, commit string) (string, error) {
cwd, _ := os.Getwd()
ctx := context.Background()
switch {
case commit != "":
return runGit(ctx, cwd, "diff", commit+"^.."+commit)
case base != "":
return runGit(ctx, cwd, "diff", base+"...HEAD")
default:
// Working tree changes: staged + unstaged.
out, err := runGit(ctx, cwd, "diff", "HEAD")
if err != nil {
return "", err
}
if out == "" {
// No working-tree changes; check for staged-only.
out, err = runGit(ctx, cwd, "diff", "--cached")
}
return out, err
}
}
func buildReviewTask(diff string, extra string) string {
var b strings.Builder
b.WriteString("Review the following changes. ")
if extra != "" {
b.WriteString(extra)
b.WriteString(" ")
}
b.WriteString("The diff is:\n\n```diff\n")
// Truncate huge diffs to protect the review subagent's context budget.
const maxLen = 16000
if len(diff) > maxLen {
b.WriteString(diff[:maxLen])
b.WriteString("\n```\n\n(diff truncated at ")
fmt.Fprint(&b, maxLen)
b.WriteString(" chars — focus on the changes shown)")
} else {
b.WriteString(diff)
b.WriteString("\n```")
}
return b.String()
}