-
Notifications
You must be signed in to change notification settings - Fork 23
Expand file tree
/
Copy pathmain.go
More file actions
430 lines (385 loc) · 14.1 KB
/
Copy pathmain.go
File metadata and controls
430 lines (385 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
/*
* ChatCLI - Command Line Interface for LLM interaction
* Copyright (c) 2024 Edilson Freitas
* License: Apache-2.0
*/
package main
import (
"context"
"fmt"
"io"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/diillson/chatcli/cli"
"github.com/diillson/chatcli/cmd"
"github.com/diillson/chatcli/config"
"github.com/diillson/chatcli/i18n"
"github.com/diillson/chatcli/llm/manager"
"github.com/diillson/chatcli/ui/theme"
"github.com/diillson/chatcli/utils"
"github.com/diillson/chatcli/version"
"github.com/joho/godotenv"
"go.uber.org/zap"
)
// isSubcommand reports whether arg is a recognized top-level subcommand and
// dispatches it. It returns true when a subcommand was handled.
func dispatchSubcommand() bool {
if len(os.Args) <= 1 {
return false
}
subcmd := os.Args[1]
switch subcmd {
case "server", "serve", "connect", "watch", "mcp-server", "mcp-serve", "acp", "gateway", "tool":
runSubcommand(subcmd, os.Args[2:])
return true
case "daemon":
runDaemonSubcommand(os.Args[2:])
return true
case "update":
runUpdateSubcommand(os.Args[2:])
return true
case "mcp":
// Config-file management only (add/list/get/remove) — no LLM
// manager, no full ChatCLI boot; mirrors `claude mcp add`.
i18n.Init()
if err := cmd.RunMCPConfig(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return true
case "plugin":
// Plugin supply chain (keygen/sign/verify/trust/quarantine) — key
// management an operator or a CI job runs, so it boots no further
// than `chatcli mcp` does.
i18n.Init()
if err := cmd.RunPluginCLI(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return true
}
return false
}
// dotenvBootstrap carries the outcome of the pre-i18n environment load so
// entrypoints can defer user-facing warnings until translations are up.
type dotenvBootstrap struct {
path string
expandErr error
loadErr error
managed config.ManagedReport
resolution config.DotenvResolution
}
// loadDotenvThenI18n resolves and loads the dotenv file and only then
// initializes i18n. Order matters: CHATCLI_LANG is documented as settable in
// .env and i18n.Init latches the language once (sync.Once) — the old
// init-first order silently ignored a dotenv-only CHATCLI_LANG, masked on
// Unix by LANG but pinning Windows cmd/PowerShell (no LANG) to English.
func loadDotenvThenI18n() dotenvBootstrap {
// config.ResolveDotenv is the single discovery rule shared by every
// entrypoint: $CHATCLI_DOTENV, else ./.env, ~/.chatcli/.env, ~/.env.
// The home fallbacks are what keep `chatcli acp` / `mcp-server` working
// when an editor spawns them without the user's shell environment.
// Snapshot what the shell — or the editor/MCP client's env block — gave
// us, BEFORE the file is loaded: /reload restores from it so a
// client-provided variable is not lost when the file does not repeat it.
config.CaptureBootEnv()
res := config.ResolveDotenv()
config.SetActiveDotenv(res)
b := dotenvBootstrap{path: res.Path, expandErr: res.ExpandErr, resolution: res}
b.loadErr = godotenv.Load(b.path)
// Organization-managed defaults and locked policies (config/managed.go):
// after the user's .env so defaults fill only what is unset, and before
// i18n so a managed CHATCLI_LANG is honored too.
b.managed = config.ApplyManaged()
i18n.Init()
return b
}
// reportDotenvBootstrap prints the deferred bootstrap warnings now that
// i18n is initialized (a missing default .env is not an error).
func reportDotenvBootstrap(b dotenvBootstrap) {
if b.expandErr != nil {
fmt.Println(i18n.T("main.warn_expand_path", b.path, b.expandErr))
}
if b.loadErr != nil && !os.IsNotExist(b.loadErr) {
fmt.Println(i18n.T("main.error_dotenv_not_found", b.path))
}
if b.managed.Err != nil {
fmt.Println(i18n.T("main.warn_managed_config", b.managed.Path, b.managed.Err))
}
}
// logDotenvResolution records which environment file the process actually
// loaded, plus the candidates considered when none was found. It is the
// first thing to check when an editor-spawned `acp`/`mcp-server` disagrees
// with the terminal about available providers or the AWS profile in use.
func logDotenvResolution(logger *zap.Logger) {
res := config.ActiveDotenv()
fields := []zap.Field{
zap.String("path", res.Path),
zap.String("origin", string(res.Origin)),
zap.Bool("exists", res.Exists),
}
if res.Exists {
logger.Info("dotenv loaded", fields...)
return
}
logger.Warn("no dotenv file found; only the process environment is in effect",
append(fields, zap.Strings("candidates", res.Candidates))...)
}
// printVersionInfo prints version details (including update check) and is used
// for the -version flag. Shares the exact same card as the /version command.
func printVersionInfo() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
fmt.Println(cli.FormatVersionReport(version.GetReport(ctx)))
}
// applyStackSpotFlags applies StackSpot realm/agent overrides when the target
// provider is StackSpot.
func applyStackSpotFlags(llmManager manager.LLMManager, targetProvider string, opts *cli.Options, logger *zap.Logger) {
if strings.ToUpper(targetProvider) != "STACKSPOT" {
return
}
if opts.Realm != "" {
llmManager.SetStackSpotRealm(opts.Realm)
logger.Info("Realm/Tenant do StackSpot sobrescrito via flag", zap.String("realm", opts.Realm))
}
if opts.AgentID != "" {
llmManager.SetStackSpotAgentID(opts.AgentID)
logger.Info("Agent ID do StackSpot sobrescrito via flag", zap.String("agent-id", opts.AgentID))
}
}
// installSignalHandlers wires process signals to the correct cancellation
// scope. It is the SINGLE handler for SIGINT and SIGTERM — having two
// independent handlers is exactly what caused the coder/agent re-entry bug
// (see below).
//
// - SIGTERM is always a shutdown request → cancel the root context.
// - SIGINT (Ctrl+C) cancels the in-flight operation when one is running
// (the interactive "interrupt this, keep the session" semantics), and only
// shuts the session down when idle at the prompt.
//
// CRITICAL: a SIGINT delivered WHILE a coder/agent operation runs must NOT
// cancel the root context. During a security confirmation the TTY is in cooked
// mode, so Ctrl+C arrives as a real signal (not a go-prompt key). If that
// cancels the root context, every later coder/agent run — which derives its
// context from root — is born already-cancelled and "doesn't fire", while chat
// keeps working on a fresh background context. The only recovery was restarting
// the process. Cancelling just the operation is the correct response; the root
// context stays alive for the rest of the session.
//
// On a clean shutdown the root cancel lets chatCLI.Start() return so the
// deferred cli.cleanup() runs (stopping MCP child processes, draining the
// scheduler, flushing history) — os.Exit() would skip every defer and orphan
// the npx-spawned MCP servers.
func installSignalHandlers(isExecuting func() bool, cancelOperation, cancelRoot func(), logger *zap.Logger) {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
for sig := range sigChan {
if sig == syscall.SIGINT && isExecuting() {
logger.Info("Cancelando operação em andamento (SIGINT)")
cancelOperation()
continue
}
logger.Info("Encerrando aplicação", zap.String("signal", sig.String()))
cancelRoot()
}
}()
}
func main() {
// Opt the legacy Windows console into ANSI/VT processing before anything
// prints (spinners, colors, \r repaints). No-op elsewhere and on handles
// that are not a console.
utils.EnableVirtualTerminal()
// Check for subcommands (server, connect) before processing standard flags.
// These subcommands have their own flag sets and should not go through cli.Parse().
if dispatchSubcommand() {
return
}
args := cli.PreprocessArgs(os.Args[1:])
opts, err := cli.Parse(args)
if err != nil {
fmt.Println(err)
os.Exit(2)
}
reportDotenvBootstrap(loadDotenvThenI18n())
if opts.Version {
printVersionInfo()
return
}
// Resolve the UI theme now that .env is loaded (CHATCLI_THEME may live
// only in the dotenv file, after the theme package's own init ran).
theme.InitFromEnv()
logger, err := utils.InitializeLogger()
if err != nil {
// CORREÇÃO: Usar Println com i18n.T
fmt.Println(i18n.T("main.error_logger_init", err))
os.Exit(1)
}
// Silence Go's default logger to prevent http2/net internal messages
// (e.g. "RoundTrip retrying after failure") from leaking to stderr.
log.SetOutput(io.Discard)
config.InitGlobal(logger)
config.Global.Load()
logDotenvResolution(logger)
utils.ApplyGlobalTLSTrust(logger)
utils.LogStartupInfo(logger)
defer func() {
if err := logger.Sync(); err != nil {
// On Windows, syncing stdout/stderr returns "invalid handle" error.
// This is a known zap issue; ignore it safely.
msg := err.Error()
if !strings.Contains(msg, "/dev/stdout") &&
!strings.Contains(msg, "/dev/stderr") &&
!strings.Contains(msg, "invalid argument") &&
!strings.Contains(msg, "inappropriate ioctl") {
fmt.Fprintf(os.Stderr, "Erro ao fechar logger: %v\n", err)
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
llmManager, err := manager.NewLLMManager(logger)
if err != nil {
logger.Fatal("Erro ao inicializar o LLMManager", zap.Error(err))
}
availableProviders := llmManager.GetAvailableProviders()
if len(availableProviders) == 0 && (opts.PromptFlagUsed || cli.HasStdin()) {
logger.Warn("Nenhum provedor LLM configurado via .env, dependendo de flags para funcionar.")
} else if len(availableProviders) == 0 {
fmt.Println(i18n.T("main.error_no_provider"))
fmt.Println("Tip: use /auth login anthropic | openai-codex to authenticate via OAuth.")
}
chatCLI, err := cli.NewChatCLI(ctx, llmManager, logger)
if err != nil {
logger.Fatal("Erro ao inicializar o ChatCLI", zap.Error(err))
}
chatCLI.UserMaxTokens = opts.MaxTokens
targetProvider := opts.Provider
if targetProvider == "" {
targetProvider = chatCLI.Provider
}
applyStackSpotFlags(llmManager, targetProvider, opts, logger)
if err := chatCLI.ApplyOverrides(ctx, llmManager, opts.Provider, opts.Model); err != nil {
// CORREÇÃO: Usar Fprintln com i18n.T
fmt.Fprintln(os.Stderr, i18n.T("main.error_apply_overrides", err))
logger.Error("Erro fatal ao aplicar overrides de provider/model via flags", zap.Error(err))
os.Exit(1)
}
installSignalHandlers(chatCLI.IsExecuting, chatCLI.CancelOperation, cancel, logger)
if chatCLI.HandleOneShotOrFatal(ctx, opts) {
return
}
chatCLI.Start(ctx)
}
// runSubcommand handles the 'server' and 'connect' subcommands.
// These have their own initialization flow separate from the standard CLI.
func runSubcommand(subcmd string, args []string) {
_ = loadDotenvThenI18n()
theme.InitFromEnv()
logger, err := utils.InitializeLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
os.Exit(1)
}
config.InitGlobal(logger)
config.Global.Load()
// stdout carries the JSON-RPC protocol on `acp`/`mcp-server`: the dotenv
// outcome goes to the log, never to the wire.
logDotenvResolution(logger)
utils.ApplyGlobalTLSTrust(logger)
defer func() {
if err := logger.Sync(); err != nil {
msg := err.Error()
if !strings.Contains(msg, "/dev/stdout") &&
!strings.Contains(msg, "/dev/stderr") &&
!strings.Contains(msg, "invalid argument") &&
!strings.Contains(msg, "inappropriate ioctl") {
fmt.Fprintf(os.Stderr, "Error closing logger: %v\n", err)
}
}
}()
llmMgr, err := manager.NewLLMManager(logger)
if err != nil {
logger.Fatal("Failed to initialize LLMManager", zap.Error(err))
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
switch subcmd {
case "server", "serve":
if err := cmd.RunServer(args, llmMgr, logger); err != nil {
logger.Fatal("Server failed", zap.Error(err))
}
case "connect":
if err := cmd.RunConnect(ctx, args, llmMgr, logger); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "watch":
if err := cmd.RunWatch(ctx, args, llmMgr, logger); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "mcp-server", "mcp-serve": // mcp-serve kept as a back-compat alias
if err := cmd.RunMCPServe(args, llmMgr, logger); err != nil {
logger.Fatal("MCP server failed", zap.Error(err))
}
case "acp":
if err := cmd.RunACP(args, llmMgr, logger); err != nil {
logger.Fatal("ACP server failed", zap.Error(err))
}
case "gateway":
if err := cmd.RunGateway(args, llmMgr, logger); err != nil {
logger.Fatal("Gateway failed", zap.Error(err))
}
case "tool":
if err := cmd.RunTool(ctx, args, llmMgr, logger); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
}
// runUpdateSubcommand handles `chatcli update [check]` — a superfície
// one-shot do /update para scripts e automação. Boot e contrato de exit code
// vivem em cli.UpdateSubcommandMain (testável); aqui resta só o os.Exit.
func runUpdateSubcommand(args []string) {
if code := cli.UpdateSubcommandMain(args); code != 0 {
os.Exit(code)
}
}
// runDaemonSubcommand handles the "daemon" subcommand. Standalone
// because the scheduler daemon does not need an LLMManager (it doesn't
// run agent tasks until a CLI attaches and delegates a bridge).
func runDaemonSubcommand(args []string) {
_ = loadDotenvThenI18n()
theme.InitFromEnv()
logger, err := utils.InitializeLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
os.Exit(1)
}
defer func() {
if err := logger.Sync(); err != nil {
msg := err.Error()
if !strings.Contains(msg, "/dev/stdout") &&
!strings.Contains(msg, "/dev/stderr") &&
!strings.Contains(msg, "invalid argument") &&
!strings.Contains(msg, "inappropriate ioctl") {
fmt.Fprintf(os.Stderr, "Error closing logger: %v\n", err)
}
}
}()
config.InitGlobal(logger)
config.Global.Load()
logDotenvResolution(logger)
utils.ApplyGlobalTLSTrust(logger)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := cmd.RunDaemon(ctx, args, logger); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}