orch-code 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +624 -0
  4. package/cmd/apply.go +111 -0
  5. package/cmd/auth.go +393 -0
  6. package/cmd/auth_test.go +100 -0
  7. package/cmd/diff.go +57 -0
  8. package/cmd/doctor.go +149 -0
  9. package/cmd/explain.go +192 -0
  10. package/cmd/explain_test.go +62 -0
  11. package/cmd/init.go +100 -0
  12. package/cmd/interactive.go +1372 -0
  13. package/cmd/interactive_input.go +45 -0
  14. package/cmd/interactive_input_test.go +55 -0
  15. package/cmd/logs.go +72 -0
  16. package/cmd/model.go +84 -0
  17. package/cmd/plan.go +149 -0
  18. package/cmd/provider.go +189 -0
  19. package/cmd/provider_model_doctor_test.go +91 -0
  20. package/cmd/root.go +67 -0
  21. package/cmd/run.go +123 -0
  22. package/cmd/run_engine.go +208 -0
  23. package/cmd/run_engine_test.go +30 -0
  24. package/cmd/session.go +589 -0
  25. package/cmd/session_helpers.go +54 -0
  26. package/cmd/session_integration_test.go +30 -0
  27. package/cmd/session_list_current_test.go +87 -0
  28. package/cmd/session_messages_test.go +163 -0
  29. package/cmd/session_runs_test.go +68 -0
  30. package/cmd/sprint1_integration_test.go +119 -0
  31. package/cmd/stats.go +173 -0
  32. package/cmd/stats_test.go +71 -0
  33. package/cmd/version.go +4 -0
  34. package/go.mod +45 -0
  35. package/go.sum +108 -0
  36. package/internal/agents/agent.go +31 -0
  37. package/internal/agents/coder.go +167 -0
  38. package/internal/agents/planner.go +155 -0
  39. package/internal/agents/reviewer.go +118 -0
  40. package/internal/agents/runtime.go +25 -0
  41. package/internal/agents/runtime_test.go +77 -0
  42. package/internal/auth/account.go +78 -0
  43. package/internal/auth/oauth.go +523 -0
  44. package/internal/auth/store.go +287 -0
  45. package/internal/confidence/policy.go +174 -0
  46. package/internal/confidence/policy_test.go +71 -0
  47. package/internal/confidence/scorer.go +253 -0
  48. package/internal/confidence/scorer_test.go +83 -0
  49. package/internal/config/config.go +331 -0
  50. package/internal/config/config_defaults_test.go +138 -0
  51. package/internal/execution/contract_builder.go +160 -0
  52. package/internal/execution/contract_builder_test.go +68 -0
  53. package/internal/execution/plan_compliance.go +161 -0
  54. package/internal/execution/plan_compliance_test.go +71 -0
  55. package/internal/execution/retry_directive.go +132 -0
  56. package/internal/execution/scope_guard.go +69 -0
  57. package/internal/logger/logger.go +120 -0
  58. package/internal/models/contracts_test.go +100 -0
  59. package/internal/models/models.go +269 -0
  60. package/internal/orchestrator/orchestrator.go +701 -0
  61. package/internal/orchestrator/orchestrator_retry_test.go +135 -0
  62. package/internal/orchestrator/review_engine_test.go +50 -0
  63. package/internal/orchestrator/state.go +42 -0
  64. package/internal/orchestrator/test_classifier_test.go +68 -0
  65. package/internal/patch/applier.go +131 -0
  66. package/internal/patch/applier_test.go +25 -0
  67. package/internal/patch/parser.go +89 -0
  68. package/internal/patch/patch.go +60 -0
  69. package/internal/patch/summary.go +30 -0
  70. package/internal/patch/validator.go +104 -0
  71. package/internal/planning/normalizer.go +416 -0
  72. package/internal/planning/normalizer_test.go +64 -0
  73. package/internal/providers/errors.go +35 -0
  74. package/internal/providers/openai/client.go +498 -0
  75. package/internal/providers/openai/client_test.go +187 -0
  76. package/internal/providers/provider.go +47 -0
  77. package/internal/providers/registry.go +32 -0
  78. package/internal/providers/registry_test.go +57 -0
  79. package/internal/providers/router.go +52 -0
  80. package/internal/providers/state.go +114 -0
  81. package/internal/providers/state_test.go +64 -0
  82. package/internal/repo/analyzer.go +188 -0
  83. package/internal/repo/context.go +83 -0
  84. package/internal/review/engine.go +267 -0
  85. package/internal/review/engine_test.go +103 -0
  86. package/internal/runstore/store.go +137 -0
  87. package/internal/runstore/store_test.go +59 -0
  88. package/internal/runtime/lock.go +150 -0
  89. package/internal/runtime/lock_test.go +57 -0
  90. package/internal/session/compaction.go +260 -0
  91. package/internal/session/compaction_test.go +36 -0
  92. package/internal/session/service.go +117 -0
  93. package/internal/session/service_test.go +113 -0
  94. package/internal/storage/storage.go +1498 -0
  95. package/internal/storage/storage_test.go +413 -0
  96. package/internal/testing/classifier.go +80 -0
  97. package/internal/testing/classifier_test.go +36 -0
  98. package/internal/tools/command.go +160 -0
  99. package/internal/tools/command_test.go +56 -0
  100. package/internal/tools/file.go +111 -0
  101. package/internal/tools/git.go +77 -0
  102. package/internal/tools/invalid_params_test.go +36 -0
  103. package/internal/tools/policy.go +98 -0
  104. package/internal/tools/policy_test.go +36 -0
  105. package/internal/tools/registry_test.go +52 -0
  106. package/internal/tools/result.go +30 -0
  107. package/internal/tools/search.go +86 -0
  108. package/internal/tools/tool.go +94 -0
  109. package/main.go +9 -0
  110. package/npm/orch.js +25 -0
  111. package/package.json +41 -0
  112. package/scripts/changelog.js +20 -0
  113. package/scripts/check-release-version.js +21 -0
  114. package/scripts/lib/release-utils.js +223 -0
  115. package/scripts/postinstall.js +157 -0
  116. package/scripts/release.js +52 -0
package/cmd/apply.go ADDED
@@ -0,0 +1,111 @@
1
+ // Package cmd implements the apply command.
2
+ package cmd
3
+
4
+ import (
5
+ "errors"
6
+ "fmt"
7
+ "os"
8
+
9
+ "github.com/furkanbeydemir/orch/internal/config"
10
+ "github.com/furkanbeydemir/orch/internal/patch"
11
+ "github.com/spf13/cobra"
12
+ )
13
+
14
+ var (
15
+ forceApply bool
16
+ approveDestructive bool
17
+ )
18
+
19
+ // applyCmd represents the `orch apply` command.
20
+ var applyCmd = &cobra.Command{
21
+ Use: "apply",
22
+ Short: "Applies generated patch",
23
+ Long: `Applies the generated patch to the working tree.
24
+ Defaults to dry-run mode. Use --force to apply changes.`,
25
+ RunE: runApply,
26
+ }
27
+
28
+ func init() {
29
+ applyCmd.Flags().BoolVar(&forceApply, "force", false, "Skip dry-run mode and apply changes")
30
+ applyCmd.Flags().BoolVar(&approveDestructive, "approve-destructive", false, "Explicitly approve destructive apply operations")
31
+ rootCmd.AddCommand(applyCmd)
32
+ }
33
+
34
+ func runApply(cmd *cobra.Command, args []string) error {
35
+ cwd, err := os.Getwd()
36
+ if err != nil {
37
+ return fmt.Errorf("failed to get working directory: %w", err)
38
+ }
39
+
40
+ cfg, err := config.Load(cwd)
41
+ if err != nil {
42
+ return fmt.Errorf("failed to load configuration: %w", err)
43
+ }
44
+
45
+ ctx, err := loadSessionContext(cwd)
46
+ if err != nil {
47
+ return err
48
+ }
49
+ defer ctx.Store.Close()
50
+
51
+ rawDiff, err := ctx.Store.LoadLatestPatchBySession(ctx.Session.ID)
52
+ if err != nil {
53
+ fmt.Println("No patch available to apply yet.")
54
+ fmt.Println("Run 'orch run <task>' first.")
55
+ return nil
56
+ }
57
+
58
+ pipeline := patch.NewPipeline(cfg.Patch.MaxFiles, cfg.Patch.MaxLines)
59
+ parsedPatch, err := pipeline.Process(rawDiff)
60
+ if err != nil {
61
+ return fmt.Errorf("invalid patch: %w", err)
62
+ }
63
+
64
+ dryRun := cfg.Safety.DryRun
65
+ if forceApply {
66
+ dryRun = false
67
+ }
68
+
69
+ if cfg.Safety.RequireDestructiveApproval && !dryRun && !approveDestructive {
70
+ return fmt.Errorf("destructive apply blocked: rerun with --approve-destructive")
71
+ }
72
+
73
+ if dryRun {
74
+ fmt.Println("🔍 Dry-run mode (patch check)...")
75
+ fmt.Println(" For real apply: orch apply --force")
76
+ } else {
77
+ fmt.Println("⚡ Applying patch (force mode)...")
78
+ }
79
+
80
+ if err := pipeline.Apply(parsedPatch, cwd, dryRun); err != nil {
81
+ var conflictErr *patch.ConflictError
82
+ if errors.As(err, &conflictErr) {
83
+ fmt.Println("❌ Patch conflict detected.")
84
+ for _, file := range conflictErr.Files {
85
+ fmt.Printf(" - %s\n", file)
86
+ }
87
+ if conflictErr.BestPatchSummary != "" {
88
+ fmt.Printf("🩹 Best Patch Summary: %s\n", conflictErr.BestPatchSummary)
89
+ }
90
+ return fmt.Errorf("patch conflict: %s", conflictErr.Reason)
91
+ }
92
+
93
+ var invalidPatchErr *patch.InvalidPatchError
94
+ if errors.As(err, &invalidPatchErr) {
95
+ if invalidPatchErr.BestPatchSummary != "" {
96
+ fmt.Printf("🩹 Best Patch Summary: %s\n", invalidPatchErr.BestPatchSummary)
97
+ }
98
+ return fmt.Errorf("invalid diff: %s", invalidPatchErr.Reason)
99
+ }
100
+
101
+ return fmt.Errorf("patch apply failed: %w", err)
102
+ }
103
+
104
+ if dryRun {
105
+ fmt.Println("✅ Dry-run succeeded. Patch is applicable.")
106
+ } else {
107
+ fmt.Println("✅ Patch applied successfully.")
108
+ }
109
+
110
+ return nil
111
+ }
package/cmd/auth.go ADDED
@@ -0,0 +1,393 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bufio"
5
+ "fmt"
6
+ "os"
7
+ "sort"
8
+ "strings"
9
+ "time"
10
+
11
+ "github.com/furkanbeydemir/orch/internal/auth"
12
+ "github.com/furkanbeydemir/orch/internal/config"
13
+ "github.com/spf13/cobra"
14
+ )
15
+
16
+ var (
17
+ authModeFlag string
18
+ authMethodFlag string
19
+ authFlowFlag string
20
+ authProviderFlag string
21
+ authEmailFlag string
22
+ authAPIKeyFlag string
23
+ )
24
+
25
+ var runOAuthLoginFlow = auth.RunOAuthFlow
26
+
27
+ var authCmd = &cobra.Command{
28
+ Use: "auth",
29
+ Short: "Manage provider credentials",
30
+ }
31
+
32
+ var authLoginCmd = &cobra.Command{
33
+ Use: "login [provider]",
34
+ Short: "Log in to a provider",
35
+ Args: cobra.MaximumNArgs(1),
36
+ RunE: runAuthLogin,
37
+ }
38
+
39
+ var authStatusCmd = &cobra.Command{
40
+ Use: "status",
41
+ Short: "Show current authentication status",
42
+ RunE: runAuthStatus,
43
+ }
44
+
45
+ var authListCmd = &cobra.Command{
46
+ Use: "list",
47
+ Aliases: []string{"ls"},
48
+ Short: "List stored credentials",
49
+ RunE: runAuthList,
50
+ }
51
+
52
+ var authLogoutCmd = &cobra.Command{
53
+ Use: "logout [provider]",
54
+ Short: "Remove stored credentials",
55
+ Args: cobra.MaximumNArgs(1),
56
+ RunE: runAuthLogout,
57
+ }
58
+
59
+ var authOpenAICmd = &cobra.Command{
60
+ Use: "openai",
61
+ Hidden: true,
62
+ Short: "Compatibility shim for old auth syntax",
63
+ }
64
+
65
+ func init() {
66
+ authLoginCmd.Flags().StringVarP(&authProviderFlag, "provider", "p", "openai", "Provider id")
67
+ authLoginCmd.Flags().StringVarP(&authMethodFlag, "method", "m", "", "Auth method: api or account")
68
+ authLoginCmd.Flags().StringVar(&authFlowFlag, "flow", "auto", "Account auth flow: auto, browser, or headless")
69
+ authLoginCmd.Flags().StringVar(&authModeFlag, "mode", "", "Deprecated: account or api_key")
70
+ authLoginCmd.Flags().StringVar(&authEmailFlag, "email", "", "Account email for status display")
71
+ authLoginCmd.Flags().StringVar(&authAPIKeyFlag, "api-key", "", "API key")
72
+
73
+ authLogoutCmd.Flags().StringVarP(&authProviderFlag, "provider", "p", "openai", "Provider id")
74
+
75
+ authCmd.AddCommand(authLoginCmd)
76
+ authCmd.AddCommand(authStatusCmd)
77
+ authCmd.AddCommand(authListCmd)
78
+ authCmd.AddCommand(authLogoutCmd)
79
+ authOpenAICmd.AddCommand(newAuthCompatLoginCmd())
80
+ authOpenAICmd.AddCommand(newAuthCompatLogoutCmd())
81
+ authOpenAICmd.AddCommand(newAuthCompatStatusCmd())
82
+ authCmd.AddCommand(authOpenAICmd)
83
+ rootCmd.AddCommand(authCmd)
84
+ }
85
+
86
+ func newAuthCompatLoginCmd() *cobra.Command {
87
+ compat := &cobra.Command{
88
+ Use: "login",
89
+ Short: "Compatibility login command",
90
+ RunE: func(cmd *cobra.Command, args []string) error {
91
+ return runAuthLogin(cmd, []string{"openai"})
92
+ },
93
+ }
94
+ compat.Flags().StringVarP(&authProviderFlag, "provider", "p", "openai", "Provider id")
95
+ compat.Flags().StringVarP(&authMethodFlag, "method", "m", "", "Auth method: api or account")
96
+ compat.Flags().StringVar(&authFlowFlag, "flow", "auto", "Account auth flow: auto, browser, or headless")
97
+ compat.Flags().StringVar(&authModeFlag, "mode", "", "Deprecated: account or api_key")
98
+ compat.Flags().StringVar(&authEmailFlag, "email", "", "Account email for status display")
99
+ compat.Flags().StringVar(&authAPIKeyFlag, "api-key", "", "API key")
100
+ return compat
101
+ }
102
+
103
+ func newAuthCompatLogoutCmd() *cobra.Command {
104
+ compat := &cobra.Command{
105
+ Use: "logout",
106
+ Short: "Compatibility logout command",
107
+ RunE: func(cmd *cobra.Command, args []string) error {
108
+ return runAuthLogout(cmd, []string{"openai"})
109
+ },
110
+ }
111
+ compat.Flags().StringVarP(&authProviderFlag, "provider", "p", "openai", "Provider id")
112
+ return compat
113
+ }
114
+
115
+ func newAuthCompatStatusCmd() *cobra.Command {
116
+ return &cobra.Command{
117
+ Use: "status",
118
+ Short: "Compatibility status command",
119
+ RunE: runAuthStatus,
120
+ }
121
+ }
122
+
123
+ func runAuthLogin(cmd *cobra.Command, args []string) error {
124
+ cwd, err := os.Getwd()
125
+ if err != nil {
126
+ return fmt.Errorf("failed to get working directory: %w", err)
127
+ }
128
+
129
+ cfg, err := config.Load(cwd)
130
+ if err != nil {
131
+ return fmt.Errorf("failed to load configuration: %w", err)
132
+ }
133
+
134
+ provider := resolveProviderArg(args)
135
+ if provider != "openai" {
136
+ return fmt.Errorf("unsupported provider: %s (supported: openai)", provider)
137
+ }
138
+
139
+ method, err := resolveAuthMethod()
140
+ if err != nil {
141
+ return err
142
+ }
143
+
144
+ if method == "api" {
145
+ if strings.TrimSpace(authFlowFlag) != "" {
146
+ flow := strings.ToLower(strings.TrimSpace(authFlowFlag))
147
+ if flow != "" && flow != "auto" {
148
+ return fmt.Errorf("--flow is only supported with --method account")
149
+ }
150
+ }
151
+
152
+ key := strings.TrimSpace(authAPIKeyFlag)
153
+ if key == "" {
154
+ fmt.Print("Paste OpenAI API key: ")
155
+ line, readErr := bufio.NewReader(os.Stdin).ReadString('\n')
156
+ if readErr != nil {
157
+ return fmt.Errorf("failed to read API key: %w", readErr)
158
+ }
159
+ key = strings.TrimSpace(line)
160
+ }
161
+ if key == "" {
162
+ return fmt.Errorf("api key cannot be empty")
163
+ }
164
+
165
+ if err := auth.Set(cwd, provider, auth.Credential{Type: "api", Key: key}); err != nil {
166
+ return err
167
+ }
168
+
169
+ cfg.Provider.OpenAI.AuthMode = "api_key"
170
+ if err := config.Save(cwd, cfg); err != nil {
171
+ return fmt.Errorf("failed to save config: %w", err)
172
+ }
173
+
174
+ fmt.Println("Credential saved to .orch/auth.json (0600).")
175
+ fmt.Printf("Auth mode set to api_key. Env %s is still supported with higher priority.\n", cfg.Provider.OpenAI.APIKeyEnv)
176
+ return nil
177
+ }
178
+
179
+ flow, err := resolveAccountFlow()
180
+ if err != nil {
181
+ return err
182
+ }
183
+
184
+ fmt.Printf("Starting OpenAI account login (%s)...\n", flow)
185
+ result, err := runOAuthLoginFlow(flow)
186
+ if err != nil {
187
+ return fmt.Errorf("account login failed: %w", err)
188
+ }
189
+ if strings.TrimSpace(result.AccessToken) == "" {
190
+ return fmt.Errorf("oauth flow returned an empty access token")
191
+ }
192
+
193
+ email := strings.TrimSpace(result.Email)
194
+ if explicit := strings.TrimSpace(authEmailFlag); explicit != "" {
195
+ email = explicit
196
+ }
197
+
198
+ if err := auth.Set(cwd, provider, auth.Credential{
199
+ Type: "oauth",
200
+ AccessToken: strings.TrimSpace(result.AccessToken),
201
+ RefreshToken: strings.TrimSpace(result.RefreshToken),
202
+ ExpiresAt: result.ExpiresAt,
203
+ AccountID: strings.TrimSpace(result.AccountID),
204
+ Email: email,
205
+ }); err != nil {
206
+ return err
207
+ }
208
+
209
+ cfg.Provider.OpenAI.AuthMode = "account"
210
+ if err := config.Save(cwd, cfg); err != nil {
211
+ return fmt.Errorf("failed to save config: %w", err)
212
+ }
213
+
214
+ fmt.Println("Credential saved to .orch/auth.json (0600).")
215
+ fmt.Printf("Auth mode set to account. You can also use %s.\n", cfg.Provider.OpenAI.AccountTokenEnv)
216
+ if !result.ExpiresAt.IsZero() {
217
+ fmt.Printf("Token expires at: %s\n", result.ExpiresAt.UTC().Format(time.RFC3339))
218
+ }
219
+ return nil
220
+ }
221
+
222
+ func runAuthStatus(cmd *cobra.Command, args []string) error {
223
+ cwd, err := os.Getwd()
224
+ if err != nil {
225
+ return fmt.Errorf("failed to get working directory: %w", err)
226
+ }
227
+
228
+ cfg, err := config.Load(cwd)
229
+ if err != nil {
230
+ return fmt.Errorf("failed to load configuration: %w", err)
231
+ }
232
+
233
+ cred, err := auth.Get(cwd, "openai")
234
+ if err != nil {
235
+ return err
236
+ }
237
+
238
+ fmt.Println("Auth Status")
239
+ fmt.Println("-----------")
240
+ fmt.Printf("provider: openai\n")
241
+ fmt.Printf("mode: %s\n", cfg.Provider.OpenAI.AuthMode)
242
+
243
+ envAPIKey := strings.TrimSpace(os.Getenv(cfg.Provider.OpenAI.APIKeyEnv)) != ""
244
+ envAccount := strings.TrimSpace(os.Getenv(cfg.Provider.OpenAI.AccountTokenEnv)) != ""
245
+ storedAPIKey := cred != nil && cred.Type == "api" && strings.TrimSpace(cred.Key) != ""
246
+ storedAccount := cred != nil && cred.Type == "oauth" && strings.TrimSpace(cred.AccessToken) != ""
247
+ storedRefresh := cred != nil && cred.Type == "oauth" && strings.TrimSpace(cred.RefreshToken) != ""
248
+
249
+ fmt.Printf("api_key_env: %s (present=%t)\n", cfg.Provider.OpenAI.APIKeyEnv, envAPIKey)
250
+ fmt.Printf("account_token_env: %s (present=%t)\n", cfg.Provider.OpenAI.AccountTokenEnv, envAccount)
251
+ fmt.Printf("stored_api_key: %t\n", storedAPIKey)
252
+ fmt.Printf("stored_account_token: %t\n", storedAccount)
253
+ fmt.Printf("stored_account_refresh: %t\n", storedRefresh)
254
+ if cred != nil && cred.Type == "oauth" && !cred.ExpiresAt.IsZero() {
255
+ fmt.Printf("account_expires_at: %s\n", cred.ExpiresAt.UTC().Format(time.RFC3339))
256
+ }
257
+ if cred != nil && cred.Type == "oauth" && strings.TrimSpace(cred.AccountID) != "" {
258
+ fmt.Printf("account_id: %s\n", cred.AccountID)
259
+ }
260
+ if cred != nil && strings.TrimSpace(cred.Email) != "" {
261
+ fmt.Printf("email: %s\n", cred.Email)
262
+ }
263
+
264
+ return nil
265
+ }
266
+
267
+ func runAuthList(cmd *cobra.Command, args []string) error {
268
+ cwd, err := os.Getwd()
269
+ if err != nil {
270
+ return fmt.Errorf("failed to get working directory: %w", err)
271
+ }
272
+
273
+ all, err := auth.LoadAll(cwd)
274
+ if err != nil {
275
+ return err
276
+ }
277
+
278
+ fmt.Println("Stored Credentials")
279
+ fmt.Println("------------------")
280
+ if len(all) == 0 {
281
+ fmt.Println("No stored credentials found.")
282
+ return nil
283
+ }
284
+
285
+ providers := make([]string, 0, len(all))
286
+ for provider := range all {
287
+ providers = append(providers, provider)
288
+ }
289
+ sort.Strings(providers)
290
+
291
+ for _, provider := range providers {
292
+ cred := all[provider]
293
+ fmt.Printf("%s (%s)\n", provider, cred.Type)
294
+ }
295
+
296
+ return nil
297
+ }
298
+
299
+ func runAuthLogout(cmd *cobra.Command, args []string) error {
300
+ cwd, err := os.Getwd()
301
+ if err != nil {
302
+ return fmt.Errorf("failed to get working directory: %w", err)
303
+ }
304
+
305
+ provider := resolveProviderArg(args)
306
+ if provider != "openai" {
307
+ return fmt.Errorf("unsupported provider: %s (supported: openai)", provider)
308
+ }
309
+
310
+ if err := auth.Remove(cwd, provider); err != nil {
311
+ return err
312
+ }
313
+
314
+ fmt.Printf("Stored credential removed for %s.\n", provider)
315
+ return nil
316
+ }
317
+
318
+ func resolveProviderArg(args []string) string {
319
+ if len(args) > 0 && strings.TrimSpace(args[0]) != "" {
320
+ return strings.ToLower(strings.TrimSpace(args[0]))
321
+ }
322
+ if strings.TrimSpace(authProviderFlag) != "" {
323
+ return strings.ToLower(strings.TrimSpace(authProviderFlag))
324
+ }
325
+ return "openai"
326
+ }
327
+
328
+ func resolveAuthMethod() (string, error) {
329
+ mode := strings.ToLower(strings.TrimSpace(authModeFlag))
330
+ if mode != "" {
331
+ if mode == "api_key" {
332
+ return "api", nil
333
+ }
334
+ if mode == "account" {
335
+ return "account", nil
336
+ }
337
+ return "", fmt.Errorf("invalid auth mode: %s (expected account or api_key)", mode)
338
+ }
339
+
340
+ method := strings.ToLower(strings.TrimSpace(authMethodFlag))
341
+ if method == "api_key" || method == "key" {
342
+ method = "api"
343
+ }
344
+ if method == "oauth" || method == "browser" || method == "headless" {
345
+ method = "account"
346
+ }
347
+ if method == "api" || method == "account" {
348
+ return method, nil
349
+ }
350
+
351
+ if strings.TrimSpace(authAPIKeyFlag) != "" {
352
+ return "api", nil
353
+ }
354
+
355
+ fmt.Println("Select auth method:")
356
+ fmt.Println(" 1) OpenAI account (browser)")
357
+ fmt.Println(" 2) API key")
358
+ fmt.Print("Choice [1-2]: ")
359
+ line, err := bufio.NewReader(os.Stdin).ReadString('\n')
360
+ if err != nil {
361
+ return "", fmt.Errorf("failed to read choice: %w", err)
362
+ }
363
+ choice := strings.TrimSpace(line)
364
+ if choice == "" || choice == "1" {
365
+ return "account", nil
366
+ }
367
+ if choice == "2" {
368
+ return "api", nil
369
+ }
370
+ return "", fmt.Errorf("invalid auth choice: %s", choice)
371
+ }
372
+
373
+ func resolveAccountFlow() (string, error) {
374
+ flow := strings.ToLower(strings.TrimSpace(authFlowFlag))
375
+ if flow == "" {
376
+ flow = "auto"
377
+ }
378
+
379
+ if flow == "auto" {
380
+ methodHint := strings.ToLower(strings.TrimSpace(authMethodFlag))
381
+ switch methodHint {
382
+ case "browser", "headless":
383
+ flow = methodHint
384
+ }
385
+ }
386
+
387
+ switch flow {
388
+ case "auto", "browser", "headless":
389
+ return flow, nil
390
+ default:
391
+ return "", fmt.Errorf("invalid account flow: %s (expected auto, browser, or headless)", flow)
392
+ }
393
+ }
@@ -0,0 +1,100 @@
1
+ package cmd
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/furkanbeydemir/orch/internal/auth"
8
+ "github.com/furkanbeydemir/orch/internal/config"
9
+ )
10
+
11
+ func TestAuthLoginAccountAndLogout(t *testing.T) {
12
+ repoRoot := t.TempDir()
13
+ t.Chdir(repoRoot)
14
+
15
+ if err := config.EnsureOrchDir(repoRoot); err != nil {
16
+ t.Fatalf("ensure orch dir: %v", err)
17
+ }
18
+ if err := config.Save(repoRoot, config.DefaultConfig()); err != nil {
19
+ t.Fatalf("save config: %v", err)
20
+ }
21
+
22
+ originalOAuthRunner := runOAuthLoginFlow
23
+ runOAuthLoginFlow = func(flow string) (auth.OAuthResult, error) {
24
+ return auth.OAuthResult{
25
+ AccessToken: "token-123",
26
+ RefreshToken: "refresh-123",
27
+ ExpiresAt: time.Now().UTC().Add(1 * time.Hour),
28
+ AccountID: "acc-123",
29
+ Email: "oauth@example.com",
30
+ }, nil
31
+ }
32
+ defer func() {
33
+ runOAuthLoginFlow = originalOAuthRunner
34
+ }()
35
+
36
+ authModeFlag = "account"
37
+ authMethodFlag = ""
38
+ authFlowFlag = "headless"
39
+ authProviderFlag = "openai"
40
+ authEmailFlag = "user@example.com"
41
+ authAPIKeyFlag = ""
42
+ if err := runAuthLogin(nil, nil); err != nil {
43
+ t.Fatalf("auth login account: %v", err)
44
+ }
45
+
46
+ state, err := auth.Load(repoRoot)
47
+ if err != nil {
48
+ t.Fatalf("load auth state: %v", err)
49
+ }
50
+ if state == nil || state.AccessToken != "token-123" {
51
+ t.Fatalf("expected stored account token")
52
+ }
53
+ if state.RefreshToken != "refresh-123" {
54
+ t.Fatalf("expected stored refresh token")
55
+ }
56
+ if state.AccountID != "acc-123" {
57
+ t.Fatalf("expected stored account id")
58
+ }
59
+
60
+ if err := runAuthLogout(nil, nil); err != nil {
61
+ t.Fatalf("auth logout: %v", err)
62
+ }
63
+ state, err = auth.Load(repoRoot)
64
+ if err != nil {
65
+ t.Fatalf("load auth state after logout: %v", err)
66
+ }
67
+ if state != nil {
68
+ t.Fatalf("expected auth state to be removed")
69
+ }
70
+ }
71
+
72
+ func TestAuthLoginAPIKeyMode(t *testing.T) {
73
+ repoRoot := t.TempDir()
74
+ t.Chdir(repoRoot)
75
+
76
+ if err := config.EnsureOrchDir(repoRoot); err != nil {
77
+ t.Fatalf("ensure orch dir: %v", err)
78
+ }
79
+ if err := config.Save(repoRoot, config.DefaultConfig()); err != nil {
80
+ t.Fatalf("save config: %v", err)
81
+ }
82
+
83
+ authModeFlag = ""
84
+ authMethodFlag = "api"
85
+ authFlowFlag = ""
86
+ authProviderFlag = "openai"
87
+ authEmailFlag = ""
88
+ authAPIKeyFlag = "sk-test"
89
+ if err := runAuthLogin(nil, nil); err != nil {
90
+ t.Fatalf("auth login api_key: %v", err)
91
+ }
92
+
93
+ cfg, err := config.Load(repoRoot)
94
+ if err != nil {
95
+ t.Fatalf("load config: %v", err)
96
+ }
97
+ if cfg.Provider.OpenAI.AuthMode != "api_key" {
98
+ t.Fatalf("expected auth mode api_key, got %s", cfg.Provider.OpenAI.AuthMode)
99
+ }
100
+ }
package/cmd/diff.go ADDED
@@ -0,0 +1,57 @@
1
+ // Package cmd implements the diff command.
2
+ package cmd
3
+
4
+ import (
5
+ "fmt"
6
+ "os"
7
+
8
+ "github.com/furkanbeydemir/orch/internal/patch"
9
+ "github.com/spf13/cobra"
10
+ )
11
+
12
+ // diffCmd represents the `orch diff` command.
13
+ var diffCmd = &cobra.Command{
14
+ Use: "diff",
15
+ Short: "Shows the generated patch",
16
+ Long: `Shows the unified diff patch from the latest run.`,
17
+ RunE: runDiff,
18
+ }
19
+
20
+ func init() {
21
+ rootCmd.AddCommand(diffCmd)
22
+ }
23
+
24
+ func runDiff(cmd *cobra.Command, args []string) error {
25
+ cwd, err := os.Getwd()
26
+ if err != nil {
27
+ return fmt.Errorf("failed to get working directory: %w", err)
28
+ }
29
+
30
+ ctx, err := loadSessionContext(cwd)
31
+ if err != nil {
32
+ return err
33
+ }
34
+ defer ctx.Store.Close()
35
+
36
+ rawDiff, err := ctx.Store.LoadLatestPatchBySession(ctx.Session.ID)
37
+ if err != nil {
38
+ fmt.Println("📄 Latest generated patch:")
39
+ fmt.Println("─────────────────────────────────────")
40
+ fmt.Println("No generated patch found yet.")
41
+ fmt.Println("Run 'orch run <task>' first.")
42
+ return nil
43
+ }
44
+
45
+ p := patch.NewPipeline(10, 800)
46
+ parsed, err := p.Process(rawDiff)
47
+ if err != nil {
48
+ return fmt.Errorf("patch parse/validation error: %w", err)
49
+ }
50
+
51
+ fmt.Println("📄 Latest generated patch:")
52
+ fmt.Println("─────────────────────────────────────")
53
+ fmt.Printf("Files: %d | Line limit: <= 800\n", len(parsed.Files))
54
+ fmt.Println()
55
+ fmt.Print(rawDiff)
56
+ return nil
57
+ }