From 5025098bbbf4efbb0190cf8bd6bc65fd0ad5f8a0 Mon Sep 17 00:00:00 2001 From: AmAzing129 Date: Fri, 8 May 2026 16:37:39 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=A7=20Honor=20config=20env=20fallb?= =?UTF-8?q?acks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/configuration.md | 2 +- internal/cli/app.go | 9 +- internal/cli/runtime.go | 4 +- internal/config/config.go | 73 ++++++++---- internal/config/config_test.go | 196 ++++++++++++++++++++++++++++++++- 5 files changed, 254 insertions(+), 30 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2c261bf..6e78a67 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,7 +64,7 @@ checkout_dir = "/Users/me/.config/gitcrawl/portable" | `embed_model` | `text-embedding-3-small` | OpenAI embedding model | | `embed_dimensions` | `1024` | Must match the model | | `embedding_basis` | `title_original` | Only `title_original` is implemented | -| `[env]` | _(empty)_ | Sets process env at startup; useful for tokens you do not want in your shell rc | +| `[env]` | _(empty)_ | Config-backed fallback after real process env for env-derived values such as tokens, DB path, and model overrides | | `[portable_store]` | _(empty)_ | Used when working from a shared, Git-backed cache | ## Environment variables diff --git a/internal/cli/app.go b/internal/cli/app.go index e2ff4a9..7913438 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1091,6 +1091,7 @@ func (a *App) runTUI(ctx context.Context, args []string) error { if cfgErr := cfg.Normalize(); cfgErr != nil { return cfgErr } + cfg.ApplyRuntimeEnv() sort, sortErr := resolveTUISort(*sortMode, cfg) if sortErr != nil { return sortErr @@ -1838,7 +1839,7 @@ func parseSyncWith(value string) (map[string]bool, error) { } func (a *App) syncRepository(ctx context.Context, owner, repo string, options syncOptions) (syncer.Stats, error) { - cfg, err := config.Load(a.configPath) + cfg, err := config.LoadRuntime(a.configPath) if err != nil { return syncer.Stats{}, err } @@ -2211,7 +2212,7 @@ func (a *App) runDoctor(ctx context.Context, args []string) error { a.applyCommandJSON(*jsonOut) _ = ctx - cfg, err := config.Load(a.configPath) + cfg, err := config.LoadRuntime(a.configPath) configExists := true if err != nil { if !errors.Is(err, os.ErrNotExist) { @@ -2222,6 +2223,7 @@ func (a *App) runDoctor(ctx context.Context, args []string) error { if err := cfg.Normalize(); err != nil { return err } + cfg.ApplyRuntimeEnv() } if err := config.EnsureRuntimeDirs(cfg); err != nil { return err @@ -2313,7 +2315,7 @@ func (a *App) runStatus(ctx context.Context, args []string) error { if fs.NArg() != 0 { return usageErr(fmt.Errorf("status takes flags only")) } - cfg, err := config.Load(a.configPath) + cfg, err := config.LoadRuntime(a.configPath) if err != nil { if !errors.Is(err, os.ErrNotExist) { return err @@ -2322,6 +2324,7 @@ func (a *App) runStatus(ctx context.Context, args []string) error { if err := cfg.Normalize(); err != nil { return err } + cfg.ApplyRuntimeEnv() } status := store.Status{DBPath: cfg.DBPath} if _, err := os.Stat(cfg.DBPath); err == nil { diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go index 1a64365..1d04ef5 100644 --- a/internal/cli/runtime.go +++ b/internal/cli/runtime.go @@ -27,7 +27,7 @@ const portableStoreRefreshTTL = 2 * time.Minute const portableStoreRefreshFailureBackoff = time.Minute func (a *App) openLocalRuntime(ctx context.Context) (localRuntime, error) { - cfg, err := config.Load(a.configPath) + cfg, err := config.LoadRuntime(a.configPath) if err != nil { return localRuntime{}, err } @@ -49,7 +49,7 @@ func (a *App) openLocalRuntime(ctx context.Context) (localRuntime, error) { } func (a *App) openLocalRuntimeReadOnly(ctx context.Context) (localRuntime, error) { - cfg, err := config.Load(a.configPath) + cfg, err := config.LoadRuntime(a.configPath) if err != nil { return localRuntime{}, err } diff --git a/internal/config/config.go b/internal/config/config.go index f8fd11d..5e5e333 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,15 +16,16 @@ const ( ) type Config struct { - Version int `toml:"version"` - DBPath string `toml:"db_path"` - CacheDir string `toml:"cache_dir"` - VectorDir string `toml:"vector_dir"` - LogDir string `toml:"log_dir"` - GitHub GitHubConfig `toml:"github"` - OpenAI OpenAIConfig `toml:"openai"` - EmbeddingBasis string `toml:"embedding_basis"` - TUI TUIConfig `toml:"tui"` + Version int `toml:"version"` + DBPath string `toml:"db_path"` + CacheDir string `toml:"cache_dir"` + VectorDir string `toml:"vector_dir"` + LogDir string `toml:"log_dir"` + Env map[string]string `toml:"env"` + GitHub GitHubConfig `toml:"github"` + OpenAI OpenAIConfig `toml:"openai"` + EmbeddingBasis string `toml:"embedding_basis"` + TUI TUIConfig `toml:"tui"` } type GitHubConfig struct { @@ -105,6 +106,15 @@ func Load(path string) (Config, error) { return cfg, nil } +func LoadRuntime(path string) (Config, error) { + cfg, err := Load(path) + if err != nil { + return Config{}, err + } + cfg.ApplyRuntimeEnv() + return cfg, nil +} + func Save(path string, cfg Config) error { if err := cfg.Normalize(); err != nil { return err @@ -151,10 +161,10 @@ func (c *Config) Normalize() error { c.OpenAI.APIKeyEnv = def.OpenAI.APIKeyEnv } if c.OpenAI.SummaryModel == "" { - c.OpenAI.SummaryModel = envOrDefault("GITCRAWL_SUMMARY_MODEL", def.OpenAI.SummaryModel) + c.OpenAI.SummaryModel = def.OpenAI.SummaryModel } if c.OpenAI.EmbedModel == "" { - c.OpenAI.EmbedModel = envOrDefault("GITCRAWL_EMBED_MODEL", def.OpenAI.EmbedModel) + c.OpenAI.EmbedModel = def.OpenAI.EmbedModel } if c.OpenAI.EmbedDimensions <= 0 { c.OpenAI.EmbedDimensions = def.OpenAI.EmbedDimensions @@ -171,34 +181,55 @@ func (c *Config) Normalize() error { if c.TUI.DefaultSort == "" { c.TUI.DefaultSort = def.TUI.DefaultSort } - c.DBPath = expandHome(envOrDefault("GITCRAWL_DB_PATH", c.DBPath)) + c.DBPath = expandHome(c.DBPath) c.CacheDir = expandHome(c.CacheDir) c.VectorDir = expandHome(c.VectorDir) c.LogDir = expandHome(c.LogDir) return nil } +func (c *Config) ApplyRuntimeEnv() { + c.OpenAI.SummaryModel = c.envOrDefault("GITCRAWL_SUMMARY_MODEL", c.OpenAI.SummaryModel) + c.OpenAI.EmbedModel = c.envOrDefault("GITCRAWL_EMBED_MODEL", c.OpenAI.EmbedModel) + c.DBPath = expandHome(c.envOrDefault("GITCRAWL_DB_PATH", c.DBPath)) +} + func ResolveGitHubToken(cfg Config) TokenResolution { - if value := strings.TrimSpace(os.Getenv(cfg.GitHub.TokenEnv)); value != "" { - return TokenResolution{Value: value, Source: cfg.GitHub.TokenEnv} - } - return TokenResolution{} + return cfg.resolveEnv(cfg.GitHub.TokenEnv) } func ResolveOpenAIKey(cfg Config) TokenResolution { - if value := strings.TrimSpace(os.Getenv(cfg.OpenAI.APIKeyEnv)); value != "" { - return TokenResolution{Value: value, Source: cfg.OpenAI.APIKeyEnv} + return cfg.resolveEnv(cfg.OpenAI.APIKeyEnv) +} + +func (c Config) resolveEnv(primary string) TokenResolution { + primary = strings.TrimSpace(primary) + if primary == "" { + return TokenResolution{} + } + if value := strings.TrimSpace(os.Getenv(primary)); value != "" { + return TokenResolution{Value: value, Source: primary} + } + if value := c.configEnv(primary); value != "" { + return TokenResolution{Value: value, Source: fmt.Sprintf("config.toml [env].%s", primary)} } return TokenResolution{} } -func envOrDefault(primary, fallback string) string { - if value := strings.TrimSpace(os.Getenv(primary)); value != "" { - return value +func (c Config) envOrDefault(primary, fallback string) string { + if resolved := c.resolveEnv(primary); resolved.Value != "" { + return resolved.Value } return fallback } +func (c Config) configEnv(primary string) string { + if c.Env == nil { + return "" + } + return strings.TrimSpace(c.Env[primary]) +} + func expandHome(path string) string { return crawlconfig.ExpandHome(path) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8d428b7..95b60b4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + crawlconfig "github.com/vincentkoc/crawlkit/config" ) func TestSaveLoadRoundTrip(t *testing.T) { @@ -13,6 +15,9 @@ func TestSaveLoadRoundTrip(t *testing.T) { cfg := Default() cfg.DBPath = filepath.Join(dir, "gitcrawl.db") cfg.OpenAI.SummaryModel = "gpt-5-mini" + cfg.Env = map[string]string{ + "GITHUB_TOKEN": "config-gh", + } if err := Save(path, cfg); err != nil { t.Fatalf("save config: %v", err) @@ -27,6 +32,9 @@ func TestSaveLoadRoundTrip(t *testing.T) { if loaded.OpenAI.SummaryModel != "gpt-5-mini" { t.Fatalf("summary model mismatch: %q", loaded.OpenAI.SummaryModel) } + if loaded.Env["GITHUB_TOKEN"] != "config-gh" { + t.Fatalf("env table mismatch: %#v", loaded.Env) + } } func TestResolvePathUsesEnv(t *testing.T) { @@ -39,7 +47,7 @@ func TestResolvePathUsesEnv(t *testing.T) { } } -func TestNormalizeUsesDBEnv(t *testing.T) { +func TestApplyRuntimeEnvUsesDBEnv(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "override.db") t.Setenv("GITCRAWL_DB_PATH", dbPath) @@ -49,6 +57,7 @@ func TestNormalizeUsesDBEnv(t *testing.T) { if err := cfg.Normalize(); err != nil { t.Fatalf("normalize: %v", err) } + cfg.ApplyRuntimeEnv() if cfg.DBPath != dbPath { t.Fatalf("db path: got %q want %q", cfg.DBPath, dbPath) } @@ -89,6 +98,187 @@ func TestResolveTokens(t *testing.T) { } } +func TestResolveTokensFromConfigEnv(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("OPENAI_API_KEY", "") + + cfg := Default() + cfg.Env = map[string]string{ + "GITHUB_TOKEN": "config-gh", + "OPENAI_API_KEY": "config-openai", + } + if got := ResolveGitHubToken(cfg); got.Value != "config-gh" || got.Source != "config.toml [env].GITHUB_TOKEN" { + t.Fatalf("github token config env mismatch: %#v", got) + } + if got := ResolveOpenAIKey(cfg); got.Value != "config-openai" || got.Source != "config.toml [env].OPENAI_API_KEY" { + t.Fatalf("openai key config env mismatch: %#v", got) + } + + t.Setenv("GITHUB_TOKEN", "process-gh") + if got := ResolveGitHubToken(cfg); got.Value != "process-gh" || got.Source != "GITHUB_TOKEN" { + t.Fatalf("process env should win: %#v", got) + } +} + +func TestResolveTokensFromCustomConfigEnv(t *testing.T) { + t.Setenv("CUSTOM_GITHUB_TOKEN", "") + t.Setenv("CUSTOM_OPENAI_KEY", "") + + cfg := Default() + cfg.GitHub.TokenEnv = "CUSTOM_GITHUB_TOKEN" + cfg.OpenAI.APIKeyEnv = "CUSTOM_OPENAI_KEY" + cfg.Env = map[string]string{ + "CUSTOM_GITHUB_TOKEN": "config-custom-gh", + "CUSTOM_OPENAI_KEY": "config-custom-openai", + "GITHUB_TOKEN": "ignored-default-gh", + "OPENAI_API_KEY": "ignored-default-openai", + } + if got := ResolveGitHubToken(cfg); got.Value != "config-custom-gh" || got.Source != "config.toml [env].CUSTOM_GITHUB_TOKEN" { + t.Fatalf("custom github token config env mismatch: %#v", got) + } + if got := ResolveOpenAIKey(cfg); got.Value != "config-custom-openai" || got.Source != "config.toml [env].CUSTOM_OPENAI_KEY" { + t.Fatalf("custom openai key config env mismatch: %#v", got) + } + + cfg.Env["CUSTOM_GITHUB_TOKEN"] = " " + if got := ResolveGitHubToken(cfg); got.Value != "" || got.Source != "" { + t.Fatalf("empty config env should be ignored: %#v", got) + } +} + +func TestApplyRuntimeEnvUsesConfigEnvFallback(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "config-env.db") + t.Setenv("GITCRAWL_DB_PATH", "") + t.Setenv("GITCRAWL_SUMMARY_MODEL", "") + t.Setenv("GITCRAWL_EMBED_MODEL", "") + + cfg := Config{ + Env: map[string]string{ + "GITCRAWL_DB_PATH": dbPath, + "GITCRAWL_SUMMARY_MODEL": "summary-config", + "GITCRAWL_EMBED_MODEL": "embed-config", + }, + } + if err := cfg.Normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + cfg.ApplyRuntimeEnv() + if cfg.DBPath != dbPath { + t.Fatalf("db path: got %q want %q", cfg.DBPath, dbPath) + } + if cfg.OpenAI.SummaryModel != "summary-config" || cfg.OpenAI.EmbedModel != "embed-config" { + t.Fatalf("config env models not used: %+v", cfg.OpenAI) + } +} + +func TestLoadRuntimeUsesConfigEnvModelOverrides(t *testing.T) { + t.Setenv("GITCRAWL_SUMMARY_MODEL", "") + t.Setenv("GITCRAWL_EMBED_MODEL", "") + + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[env] +GITCRAWL_SUMMARY_MODEL = "summary-from-config-env" +GITCRAWL_EMBED_MODEL = "embed-from-config-env" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := LoadRuntime(path) + if err != nil { + t.Fatalf("load runtime config: %v", err) + } + if cfg.OpenAI.SummaryModel != "summary-from-config-env" || cfg.OpenAI.EmbedModel != "embed-from-config-env" { + t.Fatalf("load skipped config env model overrides: %+v", cfg.OpenAI) + } +} + +func TestLoadDoesNotApplyRuntimeEnvFallback(t *testing.T) { + t.Setenv("GITCRAWL_SUMMARY_MODEL", "summary-from-process") + t.Setenv("GITCRAWL_EMBED_MODEL", "") + + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[env] +GITCRAWL_SUMMARY_MODEL = "summary-from-config-env" +GITCRAWL_EMBED_MODEL = "embed-from-config-env" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.OpenAI.SummaryModel != Default().OpenAI.SummaryModel || cfg.OpenAI.EmbedModel != Default().OpenAI.EmbedModel { + t.Fatalf("load should not apply runtime fallback: %+v", cfg.OpenAI) + } +} + +func TestSaveDoesNotApplyConfigEnvFallback(t *testing.T) { + t.Setenv("GITCRAWL_SUMMARY_MODEL", "process-summary") + t.Setenv("GITCRAWL_EMBED_MODEL", "") + + path := filepath.Join(t.TempDir(), "config.toml") + cfg := Default() + cfg.OpenAI.SummaryModel = "explicit-summary" + cfg.OpenAI.EmbedModel = "explicit-embed" + cfg.Env = map[string]string{ + "GITCRAWL_SUMMARY_MODEL": "config-summary", + "GITCRAWL_EMBED_MODEL": "config-embed", + } + + if err := Save(path, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + var raw Config + if err := crawlconfig.LoadTOML(path, &raw); err != nil { + t.Fatalf("load raw config: %v", err) + } + if raw.OpenAI.SummaryModel != "explicit-summary" { + t.Fatalf("save applied process env fallback: %q", raw.OpenAI.SummaryModel) + } + if raw.OpenAI.EmbedModel != "explicit-embed" { + t.Fatalf("save applied config env fallback: %q", raw.OpenAI.EmbedModel) + } +} + +func TestLoadThenSaveDoesNotMaterializeRuntimeEnvFallback(t *testing.T) { + t.Setenv("GITCRAWL_SUMMARY_MODEL", "process-summary") + t.Setenv("GITCRAWL_EMBED_MODEL", "") + + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[env] +GITCRAWL_SUMMARY_MODEL = "config-summary" +GITCRAWL_EMBED_MODEL = "config-embed" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("load config: %v", err) + } + cfg.EmbeddingBasis = "title_original" + if err := Save(path, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + var raw Config + if err := crawlconfig.LoadTOML(path, &raw); err != nil { + t.Fatalf("load raw config: %v", err) + } + if raw.OpenAI.SummaryModel == "process-summary" || raw.OpenAI.SummaryModel == "config-summary" { + t.Fatalf("load/save materialized summary fallback: %q", raw.OpenAI.SummaryModel) + } + if raw.OpenAI.EmbedModel == "config-embed" { + t.Fatalf("load/save materialized embed fallback: %q", raw.OpenAI.EmbedModel) + } +} + func TestNormalizeDefaultsAndRuntimeDirs(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) @@ -106,8 +296,8 @@ func TestNormalizeDefaultsAndRuntimeDirs(t *testing.T) { if cfg.Version != 1 || cfg.GitHub.TokenEnv == "" || cfg.OpenAI.APIKeyEnv == "" { t.Fatalf("defaults not filled: %+v", cfg) } - if cfg.OpenAI.SummaryModel != "summary-env" || cfg.OpenAI.EmbedModel != "embed-env" { - t.Fatalf("env models not used: %+v", cfg.OpenAI) + if cfg.OpenAI.SummaryModel != Default().OpenAI.SummaryModel || cfg.OpenAI.EmbedModel != Default().OpenAI.EmbedModel { + t.Fatalf("normalize should not apply runtime env: %+v", cfg.OpenAI) } if !filepath.IsAbs(cfg.DBPath) || !strings.Contains(cfg.DBPath, dir) { t.Fatalf("home path not expanded: %s", cfg.DBPath) From 8f2b41b2a1eb0466563aa17ba26f65498057e425 Mon Sep 17 00:00:00 2001 From: AmAzing129 Date: Fri, 8 May 2026 17:10:55 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=90=20Pass=20config=20env=20token?= =?UTF-8?q?=20to=20gh=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/gh_shim.go | 1 + internal/cli/gh_shim_cache.go | 53 +++++++++++++++++++++++++++++++++ internal/cli/gh_shim_test.go | 56 +++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) diff --git a/internal/cli/gh_shim.go b/internal/cli/gh_shim.go index be378b3..5bd9e1d 100644 --- a/internal/cli/gh_shim.go +++ b/internal/cli/gh_shim.go @@ -365,6 +365,7 @@ func (a *App) execRealGH(ctx context.Context, args []string) error { cmd.Stdin = os.Stdin cmd.Stdout = a.Stdout cmd.Stderr = a.Stderr + cmd.Env = a.realGHEnv() return cmd.Run() } diff --git a/internal/cli/gh_shim_cache.go b/internal/cli/gh_shim_cache.go index 059f8de..fc2562a 100644 --- a/internal/cli/gh_shim_cache.go +++ b/internal/cli/gh_shim_cache.go @@ -84,6 +84,58 @@ func cacheGHReadErrors() bool { return !strings.EqualFold(strings.TrimSpace(os.Getenv("GITCRAWL_GH_CACHE_ERRORS")), "0") } +func (a *App) realGHEnv() []string { + env := os.Environ() + cfg, err := config.LoadRuntime(a.configPath) + if err != nil { + return env + } + token := config.ResolveGitHubToken(cfg) + if token.Value == "" { + return env + } + tokenEnv := strings.TrimSpace(cfg.GitHub.TokenEnv) + if tokenEnv == "" { + tokenEnv = config.DefaultTokenEnv + } + env = setEnvValue(env, tokenEnv, token.Value) + if tokenEnv != config.DefaultTokenEnv && !envValueNonEmpty(env, config.DefaultTokenEnv) { + env = setEnvValue(env, config.DefaultTokenEnv, token.Value) + } + return env +} + +func setEnvValue(env []string, key, value string) []string { + key = strings.TrimSpace(key) + if key == "" { + return env + } + entry := key + "=" + value + prefix := key + "=" + out := append([]string(nil), env...) + for index, existing := range out { + if strings.HasPrefix(existing, prefix) { + out[index] = entry + return out + } + } + return append(out, entry) +} + +func envValueNonEmpty(env []string, key string) bool { + key = strings.TrimSpace(key) + if key == "" { + return false + } + prefix := key + "=" + for _, existing := range env { + if strings.HasPrefix(existing, prefix) { + return strings.TrimSpace(strings.TrimPrefix(existing, prefix)) != "" + } + } + return false +} + func (a *App) captureRealGH(ctx context.Context, args []string) (string, string, int, error) { ghPath, err := resolveRealGHPath() if err != nil { @@ -94,6 +146,7 @@ func (a *App) captureRealGH(ctx context.Context, args []string) (string, string, cmd.Stdin = os.Stdin cmd.Stdout = &stdout cmd.Stderr = &stderr + cmd.Env = a.realGHEnv() err = cmd.Run() exitCode := 0 if err != nil { diff --git a/internal/cli/gh_shim_test.go b/internal/cli/gh_shim_test.go index 89fda80..fc6579d 100644 --- a/internal/cli/gh_shim_test.go +++ b/internal/cli/gh_shim_test.go @@ -66,6 +66,62 @@ func TestGHShimFallsBackForUnsupportedRead(t *testing.T) { } } +func TestGHShimPassThroughUsesConfigEnvToken(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + if err := os.WriteFile(configPath, []byte(` +[env] +GITHUB_TOKEN = "config-token" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + ghPath := filepath.Join(dir, "gh") + if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho token:$GITHUB_TOKEN args:$*\n"), 0o755); err != nil { + t.Fatalf("write fake gh: %v", err) + } + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("GITCRAWL_GH_PATH", ghPath) + + run := New() + var stdout bytes.Buffer + run.Stdout = &stdout + if err := run.Run(ctx, []string{"--config", configPath, "gh", "auth", "status"}); err != nil { + t.Fatalf("pass-through: %v", err) + } + if got := strings.TrimSpace(stdout.String()); got != "token:config-token args:auth status" { + t.Fatalf("pass-through output = %q", got) + } +} + +func TestGHShimCachedFallbackUsesConfigEnvToken(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + if err := os.WriteFile(configPath, []byte(` +[env] +GITHUB_TOKEN = "config-token" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + ghPath := filepath.Join(dir, "gh") + if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho token:$GITHUB_TOKEN args:$*\n"), 0o755); err != nil { + t.Fatalf("write fake gh: %v", err) + } + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("GITCRAWL_GH_PATH", ghPath) + + run := New() + var stdout bytes.Buffer + run.Stdout = &stdout + if err := run.Run(ctx, []string{"--config", configPath, "gh", "repo", "view", "openclaw/gitcrawl"}); err != nil { + t.Fatalf("cached fallback: %v", err) + } + if got := strings.TrimSpace(stdout.String()); got != "token:config-token args:repo view openclaw/gitcrawl" { + t.Fatalf("cached fallback output = %q", got) + } +} + func TestGHShimFallsBackForEmptyOpenIssueListWithoutBroadSync(t *testing.T) { ctx := context.Background() configPath := seedGHShimEmptyRepo(t, ctx)