Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1839,7 +1840,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
}
Expand Down Expand Up @@ -2212,7 +2213,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) {
Expand All @@ -2223,6 +2224,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
Expand Down Expand Up @@ -2314,7 +2316,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
Expand All @@ -2323,6 +2325,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 {
Expand Down
1 change: 1 addition & 0 deletions internal/cli/gh_shim.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
53 changes: 53 additions & 0 deletions internal/cli/gh_shim_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions internal/cli/gh_shim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const portableStoreRefreshFailureBackoff = time.Minute
var errPortableStoreDirty = errors.New("portable store checkout has local changes")

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
}
Expand All @@ -52,7 +52,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
}
Expand Down
73 changes: 52 additions & 21 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
Loading