返回 DeepSeek-Reasonix
cli_flags.go
根目录 / internal / cli / cli_flags.go
1 package cli
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "unicode"
10
11 "reasonix/internal/agent"
12 )
13
14 const resumePickerSentinel = "__reasonix_resume_picker__"
15
16 func splitAllowedToolRules(values []string) ([]string, error) {
17 var rules []string
18 for _, value := range values {
19 start := -1
20 depth := 0
21 flush := func(end int) {
22 if start < 0 {
23 return
24 }
25 if rule := strings.TrimSpace(value[start:end]); rule != "" {
26 rules = append(rules, rule)
27 }
28 start = -1
29 }
30 for i, r := range value {
31 switch r {
32 case '(':
33 if start < 0 {
34 start = i
35 }
36 depth++
37 case ')':
38 if depth == 0 {
39 return nil, fmt.Errorf("invalid --allowed-tools value %q: unexpected ')'", value)
40 }
41 depth--
42 default:
43 if depth == 0 && (r == ',' || unicode.IsSpace(r)) {
44 flush(i)
45 continue
46 }
47 if start < 0 {
48 start = i
49 }
50 }
51 }
52 if depth != 0 {
53 return nil, fmt.Errorf("invalid --allowed-tools value %q: unclosed '('", value)
54 }
55 flush(len(value))
56 }
57 return uniqueStrings(rules), nil
58 }
59
60 func uniqueStrings(values []string) []string {
61 seen := make(map[string]struct{}, len(values))
62 out := make([]string, 0, len(values))
63 for _, value := range values {
64 value = strings.TrimSpace(value)
65 if value == "" {
66 continue
67 }
68 if _, ok := seen[value]; ok {
69 continue
70 }
71 seen[value] = struct{}{}
72 out = append(out, value)
73 }
74 return out
75 }
76
77 // hasLeadingPrintFlag reports whether a standalone -p/--print token appears in
78 // the top-level flag run, i.e. before any "--" terminator. reasonix has no
79 // interactive -p, so its presence means the user wants one-shot print mode even
80 // when it trails other flags (`reasonix --model X -p "task"`).
81 func hasLeadingPrintFlag(args []string) bool {
82 for _, arg := range args {
83 if arg == "--" {
84 return false
85 }
86 if arg == "-p" || arg == "--print" {
87 return true
88 }
89 }
90 return false
91 }
92
93 // stripLeadingPrintFlag drops the first standalone -p/--print token before any
94 // "--" terminator, leaving the rest (including everything after "--") untouched.
95 // Used when re-routing a top-level invocation to `run --print` so the print flag
96 // is not duplicated.
97 func stripLeadingPrintFlag(args []string) []string {
98 out := make([]string, 0, len(args))
99 dropped := false
100 for i, arg := range args {
101 if arg == "--" {
102 out = append(out, args[i:]...)
103 break
104 }
105 if !dropped && (arg == "-p" || arg == "--print") {
106 dropped = true
107 continue
108 }
109 out = append(out, arg)
110 }
111 return out
112 }
113
114 // normalizeOptionalResumeArg gives pflag the optional-value behavior Claude's
115 // --resume [value] exposes. Interactive sessions have no positional arguments,
116 // so a following non-flag token is unambiguously the resume query.
117 func normalizeOptionalResumeArg(args []string) []string {
118 out := make([]string, 0, len(args))
119 for i := 0; i < len(args); i++ {
120 arg := args[i]
121 if (arg == "--resume" || arg == "-r") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
122 out = append(out, arg+"="+args[i+1])
123 i++
124 continue
125 }
126 out = append(out, arg)
127 }
128 return out
129 }
130
131 func resolveSessionQuery(dir, query string) (cliResumeTarget, error) {
132 query = strings.TrimSpace(query)
133 if query == "" || query == resumePickerSentinel {
134 return cliResumeTarget{}, nil
135 }
136 if info, err := os.Stat(query); err == nil && !info.IsDir() {
137 abs, absErr := filepath.Abs(query)
138 if absErr != nil {
139 return cliResumeTarget{}, absErr
140 }
141 return cliResumeTarget{path: abs}, nil
142 }
143 sessions, err := agent.ListSessions(dir)
144 if err != nil {
145 return cliResumeTarget{}, fmt.Errorf("list sessions: %w", err)
146 }
147 // Opaque machine session IDs (session_<hex>) are what --events-jsonl and
148 // `session show --json` expose. Match them before preview/partial search so
149 // one-shot `run --resume` can resume without scanning private paths (#7429).
150 if looksLikeMachineSessionID(query) {
151 key, keyErr := loadMachineIdentityKey()
152 if keyErr != nil {
153 return cliResumeTarget{}, fmt.Errorf("machine identity is unavailable: %w", keyErr)
154 }
155 for _, session := range sessions {
156 if machineSessionIDWithKey(agent.BranchID(session.Path), key) == query {
157 return cliResumeTarget{path: session.Path}, nil
158 }
159 }
160 return cliResumeTarget{}, fmt.Errorf("no session matches %q", query)
161 }
162 // Final-format sessions are the same universe the picker offers: engine
163 // mirrors folded and migrated sources hidden, so a transcript and the
164 // identity it was imported under never compete as two matches.
165 scan := scanWorkspaceResume(context.Background(), dir)
166 var exact []cliResumeTarget
167 for _, session := range sessions {
168 id := agent.BranchID(session.Path)
169 base := filepath.Base(session.Path)
170 if query == id || query == base || query == session.Path {
171 exact = append(exact, cliResumeTarget{path: session.Path})
172 }
173 }
174 for _, entry := range scan.canonical {
175 if id := entry.target.ref.SessionID; query == id || query == cliCanonicalRoute(id) {
176 exact = append(exact, entry.target)
177 }
178 }
179 matches := exact
180 if len(matches) == 0 {
181 lower := strings.ToLower(query)
182 for _, session := range scan.legacy {
183 haystack := strings.ToLower(strings.Join([]string{
184 agent.BranchID(session.Path), filepath.Base(session.Path), session.CustomTitle, session.TopicTitle, session.Preview,
185 }, "\n"))
186 if strings.Contains(haystack, lower) {
187 matches = append(matches, cliResumeTarget{path: session.Path})
188 }
189 }
190 for _, entry := range scan.canonical {
191 haystack := strings.ToLower(strings.Join([]string{
192 entry.target.ref.SessionID, entry.session.CustomTitle, entry.session.Preview,
193 }, "\n"))
194 if strings.Contains(haystack, lower) {
195 matches = append(matches, entry.target)
196 }
197 }
198 }
199 switch len(matches) {
200 case 0:
201 return cliResumeTarget{}, fmt.Errorf("no session matches %q", query)
202 case 1:
203 return matches[0], nil
204 default:
205 return cliResumeTarget{}, fmt.Errorf("session query %q is ambiguous (%d matches)", query, len(matches))
206 }
207 }
208
209 // looksLikeMachineSessionID reports whether query is the opaque HMAC form
210 // emitted by machineSessionIDWithKey (`session_` + 32 lowercase hex chars).
211 func looksLikeMachineSessionID(query string) bool {
212 const prefix = "session_"
213 if !strings.HasPrefix(query, prefix) {
214 return false
215 }
216 hexPart := query[len(prefix):]
217 if len(hexPart) != 32 {
218 return false
219 }
220 for i := range len(hexPart) {
221 c := hexPart[i]
222 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
223 return false
224 }
225 }
226 return true
227 }
228
228 lines GO