From bf401226413ec7382a348ba3f617231f3d963e67 Mon Sep 17 00:00:00 2001 From: Jung Do Hyun Date: Sun, 26 Apr 2026 20:31:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20config=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20CLI=20=EC=84=A4=EC=A0=95=20=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=ED=99=9C=EC=9A=A9=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hwp2md config set`이 이슈 #19에서 명세한 키들을 모두 처리하도록 확장하고, `convert` 커맨드가 설정 파일을 실제로 읽어 동작에 반영하도록 wire-up 한다. 우선순위는 CLI 플래그 > 환경 변수 > 설정 파일 > 코드 기본값으로 통일한다. 추가된 설정 키: - parser (native, upstage) - llm.enabled (true/false) - llm.provider (openai, anthropic, gemini, upstage, ollama) - llm.model - llm.base_url (URL 형식 검증) - llm.timeout (Go 기간 문자열, 양수) Config 구조체에 `Parser`와 `LLM` 그룹을 추가하되, 기존 `default_provider`, `providers.*`, `format.*` 키는 호환성 유지를 위해 그대로 남겨둔다. 실패 시 동작 차단을 피하려고 `loadAppConfig`는 파일 부재/파싱 오류 모두 DefaultConfig로 폴백한다(플래그/환경변수가 우선이므로 안전). `llm.timeout` 키는 추가하지만, `convert.go`의 timeout 처리 wire-up은 PR #26에 의존하므로 본 PR에는 포함하지 않는다(#26 머지 후 follow-up). Closes #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 13 +++ internal/cli/cli_test.go | 171 +++++++++++++++++++++++++++++++++ internal/cli/config.go | 65 ++++++++++++- internal/cli/convert.go | 67 ++++++++----- internal/config/config.go | 24 ++++- internal/config/config_test.go | 43 +++++++++ 6 files changed, 350 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3978a33..60aef9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,19 @@ HWP/HWPX → Stage 1 (Parser) → IR → Stage 2 (LLM, optional) → Markdown | `HWP2MD_TIMEOUT` | LLM request timeout (Go duration: `5m`, `300s`, `10m30s`). Empty → provider default | | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `UPSTAGE_API_KEY` | Provider API keys | +Setting precedence (highest → lowest): CLI flag → env var → `~/.hwp2md/config.yaml` → built-in default. + +## Config File Keys (`hwp2md config set`) + +| Key | Type | Notes | +|-----|------|-------| +| `parser` | string | `native` or `upstage` | +| `llm.enabled` | bool | Mirrors `--llm` / `HWP2MD_LLM` | +| `llm.provider` | string | `openai`, `anthropic`, `gemini`, `upstage`, `ollama` | +| `llm.model` | string | Mirrors `--model` / `HWP2MD_MODEL` | +| `llm.base_url` | string | Mirrors `--base-url` / `HWP2MD_BASE_URL` | +| `llm.timeout` | duration string | Mirrors `--timeout` / `HWP2MD_TIMEOUT` (e.g. `5m`) | + ## Conventions - Korean is the primary language for CLI messages, comments, and documentation diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index a92da7d..4644101 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" "time" + + "github.com/roboco-io/hwp2md/internal/config" ) func TestSetVersion(t *testing.T) { @@ -287,3 +289,172 @@ func TestParseLLMTimeout(t *testing.T) { }) } } + +func TestResolveString(t *testing.T) { + tests := []struct { + name string + flag, env, cfg, defaultVal, want string + }{ + {"all empty falls back to default", "", "", "", "native", "native"}, + {"flag wins", "F", "E", "C", "D", "F"}, + {"env wins when flag empty", "", "E", "C", "D", "E"}, + {"config wins when flag/env empty", "", "", "C", "D", "C"}, + {"whitespace treated as empty", " ", "\t", "C", "D", "C"}, + {"all empty with empty default", "", "", "", "", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveString(tc.flag, tc.env, tc.cfg, tc.defaultVal) + if got != tc.want { + t.Errorf("resolveString(%q,%q,%q,%q) = %q, want %q", + tc.flag, tc.env, tc.cfg, tc.defaultVal, got, tc.want) + } + }) + } +} + +func TestRunConfigSet_NewKeys(t *testing.T) { + tests := []struct { + name string + key string + value string + check func(*testing.T, *config.Config) + expectErr bool + }{ + { + name: "parser native", + key: "parser", + value: "native", + check: func(t *testing.T, cfg *config.Config) { + if cfg.Parser != "native" { + t.Errorf("Parser = %q, want %q", cfg.Parser, "native") + } + }, + }, + { + name: "parser invalid", + key: "parser", + value: "bogus", + expectErr: true, + }, + { + name: "llm.enabled true", + key: "llm.enabled", + value: "true", + check: func(t *testing.T, cfg *config.Config) { + if !cfg.LLM.Enabled { + t.Error("LLM.Enabled = false, want true") + } + }, + }, + { + name: "llm.enabled invalid", + key: "llm.enabled", + value: "yesno", + expectErr: true, + }, + { + name: "llm.provider upstage", + key: "llm.provider", + value: "upstage", + check: func(t *testing.T, cfg *config.Config) { + if cfg.LLM.Provider != "upstage" { + t.Errorf("LLM.Provider = %q, want %q", cfg.LLM.Provider, "upstage") + } + }, + }, + { + name: "llm.provider invalid", + key: "llm.provider", + value: "claude", + expectErr: true, + }, + { + name: "llm.model", + key: "llm.model", + value: "gpt-4o", + check: func(t *testing.T, cfg *config.Config) { + if cfg.LLM.Model != "gpt-4o" { + t.Errorf("LLM.Model = %q, want %q", cfg.LLM.Model, "gpt-4o") + } + }, + }, + { + name: "llm.model empty", + key: "llm.model", + value: " ", + expectErr: true, + }, + { + name: "llm.base_url valid", + key: "llm.base_url", + value: "http://localhost:11434", + check: func(t *testing.T, cfg *config.Config) { + if cfg.LLM.BaseURL != "http://localhost:11434" { + t.Errorf("LLM.BaseURL = %q, want %q", cfg.LLM.BaseURL, "http://localhost:11434") + } + }, + }, + { + name: "llm.base_url no scheme", + key: "llm.base_url", + value: "localhost:11434", + expectErr: true, + }, + { + name: "llm.timeout 5m", + key: "llm.timeout", + value: "5m", + check: func(t *testing.T, cfg *config.Config) { + if cfg.LLM.Timeout != "5m" { + t.Errorf("LLM.Timeout = %q, want %q", cfg.LLM.Timeout, "5m") + } + }, + }, + { + name: "llm.timeout zero rejected", + key: "llm.timeout", + value: "0s", + expectErr: true, + }, + { + name: "llm.timeout invalid", + key: "llm.timeout", + value: "abc", + expectErr: true, + }, + { + name: "unknown key", + key: "bogus.key", + value: "1", + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + err := runConfigSet(configSetCmd, []string{tc.key, tc.value}) + if tc.expectErr { + if err == nil { + t.Fatalf("runConfigSet(%q,%q) expected error, got nil", tc.key, tc.value) + } + return + } + if err != nil { + t.Fatalf("runConfigSet(%q,%q) unexpected error: %v", tc.key, tc.value, err) + } + loader, err := config.NewLoader() + if err != nil { + t.Fatalf("NewLoader: %v", err) + } + cfg, err := loader.LoadRaw() + if err != nil { + t.Fatalf("LoadRaw: %v", err) + } + tc.check(t, cfg) + }) + } +} diff --git a/internal/cli/config.go b/internal/cli/config.go index baa1384..989ccd2 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -2,9 +2,12 @@ package cli import ( "fmt" + "net/url" "os" + "strconv" "strings" "text/tabwriter" + "time" "github.com/roboco-io/hwp2md/internal/config" "github.com/spf13/cobra" @@ -51,13 +54,23 @@ var configSetCmd = &cobra.Command{ Long: `설정 값을 변경합니다. 지원하는 키: - default_provider 기본 LLM 프로바이더 (anthropic, openai, gemini, ollama) + parser 파서 선택 (native, upstage) + llm.enabled LLM 활성화 (true/false) + llm.provider LLM 프로바이더 (openai, anthropic, gemini, upstage, ollama) + llm.model LLM 모델 이름 + llm.base_url 프라이빗 API 엔드포인트 + llm.timeout LLM 요청 타임아웃 (예: 5m, 300s) + default_provider (호환) 기본 LLM 프로바이더 format.temperature LLM 온도 (0.0-1.0) format.language 출력 언어 (ko, en) 예시: - hwp2md config set default_provider openai - hwp2md config set format.temperature 0.5`, + hwp2md config set parser native + hwp2md config set llm.enabled true + hwp2md config set llm.provider openai + hwp2md config set llm.model gpt-4o-mini + hwp2md config set llm.base_url http://localhost:11434 + hwp2md config set llm.timeout 5m`, Args: cobra.ExactArgs(2), RunE: runConfigSet, } @@ -179,6 +192,50 @@ func runConfigSet(cmd *cobra.Command, args []string) error { // Update config based on key switch key { + case "parser": + validParsers := []string{"native", "upstage"} + if !contains(validParsers, value) { + return fmt.Errorf("유효하지 않은 파서: %s (지원: %s)", value, strings.Join(validParsers, ", ")) + } + cfg.Parser = value + + case "llm.enabled": + enabled, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("유효하지 않은 bool 값: %s (true/false)", value) + } + cfg.LLM.Enabled = enabled + + case "llm.provider": + validProviders := []string{"openai", "anthropic", "gemini", "upstage", "ollama"} + if !contains(validProviders, value) { + return fmt.Errorf("유효하지 않은 프로바이더: %s (지원: %s)", value, strings.Join(validProviders, ", ")) + } + cfg.LLM.Provider = value + + case "llm.model": + if strings.TrimSpace(value) == "" { + return fmt.Errorf("모델 이름이 비어 있습니다") + } + cfg.LLM.Model = value + + case "llm.base_url": + u, err := url.Parse(value) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("유효하지 않은 URL: %s", value) + } + cfg.LLM.BaseURL = value + + case "llm.timeout": + d, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("유효하지 않은 타임아웃 값: %s (Go 기간 형식 필요, 예: 5m, 300s)", value) + } + if d <= 0 { + return fmt.Errorf("타임아웃은 양수여야 합니다: %s", value) + } + cfg.LLM.Timeout = value + case "default_provider": validProviders := []string{"anthropic", "openai", "gemini", "ollama"} if !contains(validProviders, value) { @@ -204,7 +261,7 @@ func runConfigSet(cmd *cobra.Command, args []string) error { cfg.Format.Language = value default: - return fmt.Errorf("알 수 없는 설정 키: %s\n지원하는 키: default_provider, format.temperature, format.language", key) + return fmt.Errorf("알 수 없는 설정 키: %s\n지원하는 키: parser, llm.enabled, llm.provider, llm.model, llm.base_url, llm.timeout, default_provider, format.temperature, format.language", key) } if err := loader.Save(cfg); err != nil { diff --git a/internal/cli/convert.go b/internal/cli/convert.go index 8f36614..bfedf39 100644 --- a/internal/cli/convert.go +++ b/internal/cli/convert.go @@ -119,14 +119,11 @@ func runConvert(cmd *cobra.Command, args []string) error { fmt.Fprintf(cmd.ErrOrStderr(), "파일 형식: %s\n", format) } - // Determine parser type (from flag or env) - parserType := convertParser - if parserType == "" { - parserType = os.Getenv("HWP2MD_PARSER") - } - if parserType == "" { - parserType = "native" - } + // Load app config (file may not exist; defaults used in that case). + appCfg := loadAppConfig() + + // Determine parser type (flag > env > config > default). + parserType := resolveString(convertParser, os.Getenv("HWP2MD_PARSER"), appCfg.Parser, "native") if !convertQuiet && convertVerbose { fmt.Fprintf(cmd.ErrOrStderr(), "파서: %s\n", parserType) @@ -142,8 +139,8 @@ func runConvert(cmd *cobra.Command, args []string) error { fmt.Fprintf(cmd.ErrOrStderr(), "파싱 완료: %d 블록\n", len(doc.Content)) } - // Check if LLM should be used - useLLM := convertUseLLM || config.GetEnvBool("HWP2MD_LLM") + // Check if LLM should be used (flag > env > config). + useLLM := convertUseLLM || config.GetEnvBool("HWP2MD_LLM") || appCfg.LLM.Enabled var markdown string if useLLM { @@ -152,7 +149,7 @@ func runConvert(cmd *cobra.Command, args []string) error { } // Stage 2: LLM formatting var result *llm.FormatResult - markdown, result, err = formatWithLLM(doc) + markdown, result, err = formatWithLLM(doc, appCfg) if err != nil { return fmt.Errorf("LLM 포맷팅 실패: %w", err) } @@ -249,27 +246,18 @@ func detectProviderFromModel(model string) string { } } -func formatWithLLM(doc *ir.Document) (string, *llm.FormatResult, error) { - // Determine model (from flag or env) - model := convertModel - if model == "" { - model = os.Getenv("HWP2MD_MODEL") - } - - // Determine base URL (from flag or env) for private tenancy - baseURL := convertBaseURL - if baseURL == "" { - baseURL = os.Getenv("HWP2MD_BASE_URL") - } +func formatWithLLM(doc *ir.Document, appCfg *config.Config) (string, *llm.FormatResult, error) { + // Resolve LLM settings: flag > env > config > default. + model := resolveString(convertModel, os.Getenv("HWP2MD_MODEL"), appCfg.LLM.Model, "") + baseURL := resolveString(convertBaseURL, os.Getenv("HWP2MD_BASE_URL"), appCfg.LLM.BaseURL, "") + providerName := resolveString(convertProvider, "", appCfg.LLM.Provider, "") // Determine LLM request timeout (flag > env). Zero means provider default. + // Note: config llm.timeout wire-up is a follow-up commit. timeout, err := parseLLMTimeout(convertTimeout, os.Getenv("HWP2MD_TIMEOUT")) if err != nil { return "", nil, err } - - // Auto-detect provider from model name, or use explicit flag - providerName := convertProvider if providerName == "" { providerName = detectProviderFromModel(model) } @@ -355,6 +343,33 @@ func parseLLMTimeout(flagVal, envVal string) (time.Duration, error) { return d, nil } +// loadAppConfig loads the persisted config file. +// Missing files yield DefaultConfig() so callers can read fields safely. +// Other I/O or parse errors are silently swallowed so a broken config never +// blocks document conversion; flags and env vars still take precedence. +func loadAppConfig() *config.Config { + loader, err := config.NewLoader() + if err != nil { + return config.DefaultConfig() + } + cfg, err := loader.Load() + if err != nil || cfg == nil { + return config.DefaultConfig() + } + return cfg +} + +// resolveString returns the first non-empty value among flag, env, config, +// then falls back to defaultVal. Whitespace-only entries are treated as empty. +func resolveString(flagVal, envVal, configVal, defaultVal string) string { + for _, v := range []string{flagVal, envVal, configVal} { + if strings.TrimSpace(v) != "" { + return v + } + } + return defaultVal +} + func convertToBasicMarkdown(doc *ir.Document) string { // If RawMarkdown is available (e.g., from Upstage parser), use it directly if doc.RawMarkdown != "" { diff --git a/internal/config/config.go b/internal/config/config.go index 5fcc35f..4e15bc5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,9 +3,27 @@ package config // Config represents the application configuration. type Config struct { - DefaultProvider string `yaml:"default_provider"` - Providers map[string]Provider `yaml:"providers"` - Format FormatConfig `yaml:"format"` + // Parser selects the document parser: "native" (default) or "upstage". + Parser string `yaml:"parser,omitempty"` + // LLM contains the active LLM settings used by the convert command. + LLM LLMConfig `yaml:"llm,omitempty"` + + // DefaultProvider/Providers/Format are retained for backward compatibility + // with existing config files and the legacy `format.*` set keys. + DefaultProvider string `yaml:"default_provider,omitempty"` + Providers map[string]Provider `yaml:"providers,omitempty"` + Format FormatConfig `yaml:"format,omitempty"` +} + +// LLMConfig holds settings for the LLM stage of the convert command. +// Empty fields fall back to environment variables and provider defaults. +type LLMConfig struct { + Enabled bool `yaml:"enabled,omitempty"` + Provider string `yaml:"provider,omitempty"` + Model string `yaml:"model,omitempty"` + BaseURL string `yaml:"base_url,omitempty"` + // Timeout is a Go duration string (e.g. "5m", "300s"). Empty means default. + Timeout string `yaml:"timeout,omitempty"` } // Provider represents an LLM provider configuration. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b1f2d8d..01def11 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -289,3 +289,46 @@ format: t.Errorf("expected empty API key for unset env var, got %s", testProvider.APIKey) } } + +func TestConfig_LLMRoundTrip(t *testing.T) { + tmpDir := t.TempDir() + loader := NewLoaderWithPath(filepath.Join(tmpDir, "config.yaml")) + + src := DefaultConfig() + src.Parser = "upstage" + src.LLM = LLMConfig{ + Enabled: true, + Provider: "openai", + Model: "gpt-4o-mini", + BaseURL: "http://localhost:8080", + Timeout: "5m", + } + + if err := loader.Save(src); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, err := loader.Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if loaded.Parser != "upstage" { + t.Errorf("Parser = %q, want %q", loaded.Parser, "upstage") + } + if !loaded.LLM.Enabled { + t.Error("LLM.Enabled = false, want true") + } + if loaded.LLM.Provider != "openai" { + t.Errorf("LLM.Provider = %q, want %q", loaded.LLM.Provider, "openai") + } + if loaded.LLM.Model != "gpt-4o-mini" { + t.Errorf("LLM.Model = %q, want %q", loaded.LLM.Model, "gpt-4o-mini") + } + if loaded.LLM.BaseURL != "http://localhost:8080" { + t.Errorf("LLM.BaseURL = %q, want %q", loaded.LLM.BaseURL, "http://localhost:8080") + } + if loaded.LLM.Timeout != "5m" { + t.Errorf("LLM.Timeout = %q, want %q", loaded.LLM.Timeout, "5m") + } +} From 7e7434cf1595b183272b97cb739c1715b605b25a Mon Sep 17 00:00:00 2001 From: Jung Do Hyun Date: Mon, 27 Apr 2026 18:16:05 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20parseLLMTimeout=EC=97=90=20config?= =?UTF-8?q?=20llm.timeout=20fallback=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #19 description에서 PR #26 머지 후 follow-up으로 약속한 마지막 결합. `parseLLMTimeout` 시그니처에 `configVal` 파라미터를 추가해 우선순위 flag > env > config > provider default를 단일 resolver로 처리한다. 잘못된 값 입력 시 source-specific 에러("--timeout"/"HWP2MD_TIMEOUT"/ "config llm.timeout")로 어디서 들어왔는지 명확히 알린다. 테스트는 13 → 18 케이스로 확장: config 정상 fallback, env/flag와의 우선순위 교차, whitespace 폴백, source-specific 에러 검증을 추가. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cli/cli_test.go | 22 ++++++++++++++-------- internal/cli/convert.go | 20 +++++++++++--------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4644101..78e44f7 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -248,20 +248,26 @@ func TestParseLLMTimeout(t *testing.T) { name string flag string env string + cfg string want time.Duration wantErr bool wantErrContain string // optional substring expected in the error message }{ - {name: "both empty falls back to provider default", flag: "", env: "", want: 0}, + {name: "all empty falls back to provider default", want: 0}, {name: "flag value parsed", flag: "5m", want: 5 * time.Minute}, {name: "env value parsed when flag empty", env: "300s", want: 300 * time.Second}, + {name: "config value parsed when flag/env empty", cfg: "2m", want: 2 * time.Minute}, {name: "flag overrides env", flag: "10m", env: "30s", want: 10 * time.Minute}, + {name: "flag overrides config", flag: "10m", cfg: "30s", want: 10 * time.Minute}, + {name: "env overrides config", env: "30s", cfg: "5m", want: 30 * time.Second}, {name: "compound duration", flag: "10m30s", want: 10*time.Minute + 30*time.Second}, {name: "whitespace trimmed", flag: " 2m ", want: 2 * time.Minute}, {name: "whitespace flag falls back to env", flag: " ", env: "5m", want: 5 * time.Minute}, - {name: "whitespace env returns provider default", env: "\t", want: 0}, + {name: "whitespace flag/env falls back to config", flag: " ", env: "\t", cfg: "1m", want: time.Minute}, + {name: "whitespace everywhere returns provider default", env: "\t", cfg: " ", want: 0}, {name: "invalid flag is reported as flag", flag: "abc", env: "5m", wantErr: true, wantErrContain: "--timeout"}, {name: "invalid env is reported as env", env: "abc", wantErr: true, wantErrContain: "HWP2MD_TIMEOUT"}, + {name: "invalid config is reported as config", cfg: "abc", wantErr: true, wantErrContain: "llm.timeout"}, {name: "invalid env returns error", env: "5", wantErr: true}, {name: "zero rejected", flag: "0s", wantErr: true, wantErrContain: "양수"}, {name: "negative rejected", flag: "-1s", wantErr: true, wantErrContain: "양수"}, @@ -269,22 +275,22 @@ func TestParseLLMTimeout(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got, err := parseLLMTimeout(tc.flag, tc.env) + got, err := parseLLMTimeout(tc.flag, tc.env, tc.cfg) if tc.wantErr { if err == nil { - t.Fatalf("parseLLMTimeout(%q, %q) expected error, got nil", tc.flag, tc.env) + t.Fatalf("parseLLMTimeout(%q, %q, %q) expected error, got nil", tc.flag, tc.env, tc.cfg) } if tc.wantErrContain != "" && !strings.Contains(err.Error(), tc.wantErrContain) { - t.Errorf("parseLLMTimeout(%q, %q) error %q does not contain %q", - tc.flag, tc.env, err.Error(), tc.wantErrContain) + t.Errorf("parseLLMTimeout(%q, %q, %q) error %q does not contain %q", + tc.flag, tc.env, tc.cfg, err.Error(), tc.wantErrContain) } return } if err != nil { - t.Fatalf("parseLLMTimeout(%q, %q) unexpected error: %v", tc.flag, tc.env, err) + t.Fatalf("parseLLMTimeout(%q, %q, %q) unexpected error: %v", tc.flag, tc.env, tc.cfg, err) } if got != tc.want { - t.Errorf("parseLLMTimeout(%q, %q) = %v, want %v", tc.flag, tc.env, got, tc.want) + t.Errorf("parseLLMTimeout(%q, %q, %q) = %v, want %v", tc.flag, tc.env, tc.cfg, got, tc.want) } }) } diff --git a/internal/cli/convert.go b/internal/cli/convert.go index bfedf39..93349cf 100644 --- a/internal/cli/convert.go +++ b/internal/cli/convert.go @@ -252,9 +252,8 @@ func formatWithLLM(doc *ir.Document, appCfg *config.Config) (string, *llm.Format baseURL := resolveString(convertBaseURL, os.Getenv("HWP2MD_BASE_URL"), appCfg.LLM.BaseURL, "") providerName := resolveString(convertProvider, "", appCfg.LLM.Provider, "") - // Determine LLM request timeout (flag > env). Zero means provider default. - // Note: config llm.timeout wire-up is a follow-up commit. - timeout, err := parseLLMTimeout(convertTimeout, os.Getenv("HWP2MD_TIMEOUT")) + // Determine LLM request timeout (flag > env > config). Zero means provider default. + timeout, err := parseLLMTimeout(convertTimeout, os.Getenv("HWP2MD_TIMEOUT"), appCfg.LLM.Timeout) if err != nil { return "", nil, err } @@ -317,18 +316,21 @@ func formatWithLLM(doc *ir.Document, appCfg *config.Config) (string, *llm.Format return result.Markdown, result, nil } -// parseLLMTimeout resolves the LLM request timeout from the --timeout flag -// or the HWP2MD_TIMEOUT environment variable. The flag takes precedence; -// when both are empty it returns 0 so each provider applies its own default. -// The value must be a Go duration string (e.g. "5m", "300s", "10m30s") and -// must be positive. -func parseLLMTimeout(flagVal, envVal string) (time.Duration, error) { +// parseLLMTimeout resolves the LLM request timeout in priority order: +// --timeout flag > HWP2MD_TIMEOUT env > config llm.timeout. When all are +// empty it returns 0 so each provider applies its own default. The value +// must be a Go duration string (e.g. "5m", "300s", "10m30s") and positive. +func parseLLMTimeout(flagVal, envVal, configVal string) (time.Duration, error) { raw := strings.TrimSpace(flagVal) source := "--timeout" if raw == "" { raw = strings.TrimSpace(envVal) source = "HWP2MD_TIMEOUT" } + if raw == "" { + raw = strings.TrimSpace(configVal) + source = "config llm.timeout" + } if raw == "" { return 0, nil } From b9ed112791683c0a75eafb2e3afafe8f5111820c Mon Sep 17 00:00:00 2001 From: Jung Do Hyun Date: Tue, 28 Apr 2026 07:04:24 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20TestRunConfigSet=5FNewKeys=EC=97=90?= =?UTF-8?q?=20USERPROFILE=20=EA=B2=A9=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows의 os.UserHomeDir()은 HOME이 아닌 USERPROFILE을 보므로 t.Setenv(\"HOME\", tmpHome)만으로는 격리되지 않아 진짜 user home에 config 파일을 만들고, 같은 runner의 e2e 테스트가 그 파일을 읽어 LLM Stage 2(upstage)를 켜면서 UPSTAGE_API_KEY 부재로 실패했다. HOME과 USERPROFILE을 모두 tmpDir로 redirect해 OS와 무관하게 격리되도록 한다. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cli/cli_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 78e44f7..e9396b3 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -440,7 +440,10 @@ func TestRunConfigSet_NewKeys(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpHome := t.TempDir() + // Cover both Unix (HOME) and Windows (USERPROFILE) home lookups + // so config.NewLoader() never touches the real user home. t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) err := runConfigSet(configSetCmd, []string{tc.key, tc.value}) if tc.expectErr {