{"repo": "amjad2161/agency-agents", "files": {"agency-agents/integrations/external_repos/decepticon/clients/launcher/internal/config/config.go": {"3b48b031d145eef733c72fdec0242a6469675d16": {"t": 1785745632.4371157, "n": 16488, "h": "e6e2e6132034e768", "c": "package config\n\nimport (\n\t\"bufio\"\n\t_ \"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n//go:embed env.example\nvar EnvTemplate string\n\nconst (\n\tDefaultHome = \".decepticon\"\n\tEnvFileName = \".env\"\n\tEnvExampleName = \".env.example\"\n\tPlaceholderSuffix = \"-key-here\"\n)\n\n// Config holds the Decepticon launcher configuration.\ntype Config struct {\n\tHome string\n\tEnv map[string]string\n}\n\n// DecepticonHome returns the resolved DECEPTICON_HOME path.\nfunc DecepticonHome() string {\n\tif h := os.Getenv(\"DECEPTICON_HOME\"); h != \"\" {\n\t\treturn h\n\t}\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn filepath.Join(\"~\", DefaultHome)\n\t}\n\treturn filepath.Join(home, DefaultHome)\n}\n\n// EnvPath returns the full path to the .env file.\nfunc EnvPath() string {\n\treturn filepath.Join(DecepticonHome(), EnvFileName)\n}\n\n// EnvExists checks whether .env exists.\nfunc EnvExists() bool {\n\t_, err := os.Stat(EnvPath())\n\treturn err == nil\n}\n\n// LoadEnv reads a .env file and returns key-value pairs.\n// It handles comments (#), empty lines, and optional quoting.\nfunc LoadEnv(path string) (map[string]string, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"open env file: %w\", err)\n\t}\n\tdefer f.Close()\n\n\tenv := make(map[string]string)\n\tscanner := bufio.NewScanner(f)\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\t\tkey, val, ok := parseEnvLine(line)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tenv[key] = val\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"read env file: %w\", err)\n\t}\n\treturn env, nil\n}\n\n// parseEnvLine splits \"KEY=VALUE\" handling optional quotes.\nfunc parseEnvLine(line string) (key, val string, ok bool) {\n\tidx := strings.IndexByte(line, '=')\n\tif idx < 1 {\n\t\treturn \"\", \"\", false\n\t}\n\tkey = strings.TrimSpace(line[:idx])\n\tval = strings.TrimSpace(line[idx+1:])\n\t// Strip surrounding quotes\n\tif len(val) >= 2 {\n\t\tif (val[0] == '\"' && val[len(val)-1] == '\"') ||\n\t\t\t(val[0] == '\\'' && val[len(val)-1] == '\\'') {\n\t\t\tval = val[1 : len(val)-1]\n\t\t}\n\t}\n\treturn key, val, true\n}\n\n// WriteEnvFromEmbed writes key-value pairs into a .env file using the embedded template.\nfunc WriteEnvFromEmbed(outputPath string, values map[string]string) error {\n\treturn writeEnvFromString(EnvTemplate, outputPath, values)\n}\n\n// WriteEnv writes key-value pairs into a .env file using a template file.\nfunc WriteEnv(templatePath, outputPath string, values map[string]string) error {\n\ttmpl, err := os.ReadFile(templatePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"read template: %w\", err)\n\t}\n\treturn writeEnvFromString(string(tmpl), outputPath, values)\n}\n\nfunc writeEnvFromString(tmpl string, outputPath string, values map[string]string) error {\n\tlines := strings.Split(tmpl, \"\\n\")\n\tvar out []string\n\tfor _, line := range lines {\n\t\ttrimmed := strings.TrimSpace(line)\n\t\t// Skip commented lines — keep as-is\n\t\tif trimmed == \"\" || strings.HasPrefix(trimmed, \"#\") {\n\t\t\tout = append(out, line)\n\t\t\tcontinue\n\t\t}\n\t\tkey, _, ok := parseEnvLine(trimmed)\n\t\tif !ok {\n\t\t\tout = append(out, line)\n\t\t\tcontinue\n\t\t}\n\t\tif newVal, exists := values[key]; exists {\n\t\t\tout = append(out, key+\"=\"+newVal)\n\t\t} else {\n\t\t\tout = append(out, line)\n\t\t}\n\t}\n\n\tif err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {\n\t\treturn fmt.Errorf(\"create directory: %w\", err)\n\t}\n\treturn os.WriteFile(outputPath, []byte(strings.Join(out, \"\\n\")), 0o600)\n}\n\n// APIKeyNames lists the API key environment variable names to check.\n// Order matters: a valid key in this list satisfies the \"at least one\n// credential\" startup gate. Keep in sync with keyFormatRules below and\n// with decepticon/llm/factory.py::_API_METHOD_ENV.\nvar APIKeyNames = []string{\n\t\"ANTHROPIC_API_KEY\",\n\t\"OPENAI_API_KEY\",\n\t\"GEMINI_API_KEY\",\n\t\"MINIMAX_API_KEY\",\n\t\"OPENROUTER_API_KEY\",\n\t\"NVIDIA_API_KEY\",\n\t\"DEEPSEEK_API_KEY\",\n\t\"XAI_API_KEY\",\n\t\"MISTRAL_API_KEY\",\n}\n\n// IsPlaceholder checks if a value looks like a placeholder.\nfunc IsPlaceholder(val string) bool {\n\treturn strings.HasSuffix(val, PlaceholderSuffix) || val == \"\"\n}\n\n// keyFormatRules maps an API key env var to its expected prefix and human-readable hint.\n// Format checks are intentionally lenient — providers occasionally evolve key shapes\n// (OpenAI shipped sk-proj-* in 2024, Anthropic sk-ant-api03-* etc.). The check only\n// rejects values that are obviously malformed (typos, missing prefix).\n//\n// An empty Prefix means \"no fixed shape, skip the prefix check\" (only the\n// length floor still applies). MiniMax/Mistral keys ship without a\n// universal prefix and would otherwise be rejected when the user pastes\n// the right key.\nvar keyFormatRules = map[string]struct {\n\tPrefix string\n\tHint string\n}{\n\t\"ANTHROPIC_API_KEY\": {Prefix: \"sk-\", Hint: \"Anthropic keys start with 'sk-'\"},\n\t\"OPENAI_API_KEY\": {Prefix: \"sk-\", Hint: \"OpenAI keys start with 'sk-'\"},\n\t\"GEMINI_API_KEY\": {Prefix: \"AIza\", Hint: \"Gemini keys start with 'AIza'\"},\n\t\"OPENROUTER_API_KEY\": {Prefix: \"sk-or-\", Hint: \"OpenRouter keys start with 'sk-or-'\"},\n\t\"NVIDIA_API_KEY\": {Prefix: \"nvapi-\", Hint: \"NVIDIA keys start with 'nvapi-'\"},\n\t\"DEEPSEEK_API_KEY\": {Prefix: \"sk-\", Hint: \"DeepSeek keys start with 'sk-'\"},\n\t\"XAI_API_KEY\": {Prefix: \"xai-\", Hint: \"xAI keys start with 'xai-'\"},\n\t\"MINIMAX_API_KEY\": {Prefix: \"\", Hint: \"\"},\n\t\"MISTRAL_API_KEY\": {Prefix: \"\", Hint: \"\"},\n}\n\n// validateKeyFormat returns an empty string if the key looks valid, or a reason if not.\nfunc validateKeyFormat(name, val string) string {\n\tif len(val) < 20 {\n\t\treturn \"value is too short to be a valid API key\"\n\t}\n\tif rule, ok := keyFormatRules[name]; ok {\n\t\t// Empty Prefix → provider has no fixed prefix, length floor is the\n\t\t// only signal. Skip the prefix check rather than rejecting valid keys.\n\t\tif rule.Prefix != \"\" && !strings.HasPrefix(val, rule.Prefix) {\n\t\t\treturn rule.Hint\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ValidateAPIKeys checks that at least one API key is set with a valid format.\n// Returns a fatal error listing both unset keys and any format problems found.\nfunc ValidateAPIKeys(env map[string]string) error {\n\tvar validNames []string\n\tinvalidReasons := make(map[string]string)\n\n\tfor _, name := range APIKeyNames {\n\t\tval := env[name]\n\t\tif val == \"\" || IsPlaceholder(val) {\n\t\t\tcontinue\n\t\t}\n\t\tif reason := validateKeyFormat(name, val); reason != \"\" {\n\t\t\tinvalidReasons[name] = reason\n\t\t\tcontinue\n\t\t}\n\t\tvalidNames = append(validNames, name)\n\t}\n\n\tif len(validNames) > 0 {\n\t\treturn nil\n\t}\n\n\tvar msg strings.Builder\n\tmsg.WriteString(\"no valid API key found.\")\n\tif len(invalidReasons) > 0 {\n\t\tmsg.WriteString(\" Detected malformed key(s):\")\n\t\tfor name, reason := range invalidReasons {\n\t\t\tmsg.WriteString(fmt.Sprintf(\"\\n %s: %s\", name, reason))\n\t\t}\n\t}\n\tmsg.WriteString(\"\\nRun 'decepticon onboard --reset' to reconfigure credentials.\")\n\treturn fmt.Errorf(\"%s\", msg.String())\n}\n\n// subscriptionMethod groups the env signal + credential layout for one\n// OAuth subscription handler so we don't repeat the validation logic.\n// Resolution order matches each handler in config/*_handler.py exactly:\n//\n//\t1. _ACCESS_TOKEN — pre-extracted Bearer\n//\t2. _SESSION_TOKEN — browser session cookie value\n//\t3. ~/.config//tokens.json on disk\n//\n// Two providers diverge slightly:\n// - Gemini Advanced ships a multi-cookie value (GEMINI_SESSION_COOKIES)\n// instead of a single session token. accept either env name.\n// - Copilot Pro uses a refresh-token rotation (COPILOT_REFRESH_TOKEN)\n// instead of a session cookie. Same fall-through, different env name.\ntype subscriptionMethod struct {\n\tToggle string // DECEPTICON_AUTH_ boolean enabling this path\n\tTokenEnvs []string // env vars that satisfy the path on their own\n\tConfigDir string // ~/.config//tokens.json fallback\n\tLabel string // human name for error messages\n}\n\nvar oauthSubscriptions = map[string]subscriptionMethod{\n\t\"chatgpt\": {\n\t\tToggle: \"DECEPTICON_AUTH_CHATGPT\",\n\t\tTokenEnvs: []string{\"CHATGPT_ACCESS_TOKEN\", \"CHATGPT_SESSION_TOKEN\"},\n\t\tConfigDir: \"chatgpt\",\n\t\tLabel: \"ChatGPT\",\n\t},\n\t\"gemini\": {\n\t\tToggle: \"DECEPTICON_AUTH_GEMINI\",\n\t\tTokenEnvs: []string{\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"},\n\t\tConfigDir: \"gemini\",\n\t\tLabel: \"Gemini Advanced\",\n\t},\n\t\"copilot\": {\n\t\tToggle: \"DECEPTICON_AUTH_COPILOT\",\n\t\tTokenEnvs: []string{\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"},\n\t\tConfigDir: \"copilot\",\n\t\tLabel: \"Copilot Pro\",\n\t},\n\t\"grok\": {\n\t\tToggle: \"DECEPTICON_AUTH_GROK\",\n\t\tTokenEnvs: []string{\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"},\n\t\tConfigDir: \"grok\",\n\t\tLabel: \"SuperGrok\",\n\t},\n\t\"perplexity\": {\n\t\tToggle: \"DECEPTICON_AUTH_PERPLEXITY\",\n\t\tTokenEnvs: []string{\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"},\n\t\tConfigDir: \"perplexity\",\n\t\tLabel: \"Perplexity Pro\",\n\t},\n}\n\n// ValidateAuth ensures at least one valid AuthMethod is configured.\n//\n// OAuth paths:\n// - DECEPTICON_AUTH_CLAUDE_CODE=true requires a parseable\n// ~/.claude/.credentials.json. LiteLLM mounts that file read-only.\n// - DECEPTICON_AUTH_=true (CHATGPT, GEMINI, COPILOT, GROK,\n// PERPLEXITY) is satisfied by a token env var or a tokens.json\n// file under ~/.config//. See subscriptionMethod above.\n//\n// Local LLM path: ollama_local in DECEPTICON_AUTH_PRIORITY (or any\n// OLLAMA_API_BASE configured) is treated as a valid credential. Ollama\n// reachability is probed separately at startup (start.go).\n//\n// API path: at least one configured provider key (ANTHROPIC / OPENAI /\n// GEMINI / MINIMAX / OPENROUTER / NVIDIA / DEEPSEEK / XAI / MISTRAL)\n// must be non-placeholder and well-formed.\n//\n// At least one path must succeed. When OAuth is requested and its\n// credentials are broken, the other paths are checked as fallbacks;\n// if all fail, the OAuth error is surfaced first because that was the\n// user's explicit choice.\nfunc ValidateAuth(env map[string]string) error {\n\tclaudeOAuth := isTruthy(Get(env, \"DECEPTICON_AUTH_CLAUDE_CODE\", \"\"))\n\tollamaErr := validateOllamaCredentials(env)\n\tapiErr := ValidateAPIKeys(env)\n\n\tif claudeOAuth {\n\t\tif err := validateClaudeCredentials(); err == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\tfor _, sub := range oauthSubscriptions {\n\t\tif !isTruthy(Get(env, sub.Toggle, \"\")) {\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateSubscriptionCredentials(env, sub); err == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\tif ollamaErr == nil {\n\t\treturn nil\n\t}\n\tif apiErr == nil {\n\t\treturn nil\n\t}\n\n\t// All paths failed — surface the user's explicit choice first.\n\tif claudeOAuth {\n\t\treturn validateClaudeCredentials()\n\t}\n\tfor _, key := range []string{\"chatgpt\", \"gemini\", \"copilot\", \"grok\", \"perplexity\"} {\n\t\tsub := oauthSubscriptions[key]\n\t\tif isTruthy(Get(env, sub.Toggle, \"\")) {\n\t\t\treturn validateSubscriptionCredentials(env, sub)\n\t\t}\n\t}\n\tif hasOllamaSelected(env) {\n\t\treturn ollamaErr\n\t}\n\treturn apiErr\n}\n\n// hasOllamaSelected returns true when the user has explicitly opted into\n// the local-Ollama auth method (via the priority list or by setting\n// OLLAMA_API_BASE without any other API key).\nfunc hasOllamaSelected(env map[string]string) bool {\n\tpriority := strings.ToLower(strings.TrimSpace(env[\"DECEPTICON_AUTH_PRIORITY\"]))\n\tfor _, m := range strings.Split(priority, \",\") {\n\t\tif strings.TrimSpace(m) == \"ollama_local\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn env[\"OLLAMA_API_BASE\"] != \"\"\n}\n\n// validateOllamaCredentials accepts the Ollama path when the user has\n// either listed ``ollama_local`` in DECEPTICON_AUTH_PRIORITY or set\n// ``OLLAMA_API_BASE`` directly. Ollama itself has no API key — the\n// only required signal is the base URL pointing at a running instance.\n//\n// We don't probe the URL here; the launcher runs on the host while the\n// LiteLLM container talks to the URL from inside Docker, so a host-side\n// reachability check would lie when the user (correctly) wired up\n// host.docker.internal-style addressing.\nfunc validateOllamaCredentials(env map[string]string) error {\n\tif !hasOllamaSelected(env) {\n\t\treturn fmt.Errorf(\"ollama not configured\")\n\t}\n\tbase := strings.TrimSpace(env[\"OLLAMA_API_BASE\"])\n\tif base == \"\" {\n\t\treturn fmt.Errorf(\n\t\t\t\"ollama_local selected but OLLAMA_API_BASE is empty.\\n\" +\n\t\t\t\t\"Set OLLAMA_API_BASE=http://host.docker.internal:11434 (or your Ollama URL) \" +\n\t\t\t\t\"in ~/.decepticon/.env, or run 'decepticon onboard --reset' to reconfigure.\",\n\t\t)\n\t}\n\treturn nil\n}\n\nfunc isTruthy(s string) bool {\n\tswitch strings.ToLower(strings.TrimSpace(s)) {\n\tcase \"true\", \"1\", \"yes\", \"on\":\n\t\treturn true\n\t}\n\treturn false\n}\n\n// validateClaudeCredentials verifies ~/.claude/.credentials.json exists, is a regular\n// file, parses as JSON, and carries an access token in one of the shapes the LiteLLM\n// claude_code_handler accepts (claudeAiOauth.accessToken, top-level accessToken, or\n// legacy oauthToken). Compose mounts this path into the LiteLLM container; if it's\n// missing or empty, authentication fails opaquely on the first prompt instead of here.\nfunc validateClaudeCredentials() error {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"locate home directory: %w\", err)\n\t}\n\tpath := filepath.Join(home, \".claude\", \".credentials.json\")\n\tinfo, err := os.Stat(path)\n\tif os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"Claude Code credentials not found at %s\\nRun 'claude /login' (Claude Code CLI) to authenticate, then retry.\", path)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"stat %s: %w\", path, err)\n\t}\n\tif info.IsDir() {\n\t\treturn fmt.Errorf(\"expected credentials file at %s but found a directory.\\nRemove it and run 'claude /login' to re-authenticate.\", path)\n\t}\n\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"read %s: %w\\nCheck file permissions and re-run.\", path, err)\n\t}\n\tvar creds map[string]any\n\tif err := json.Unmarshal(raw, &creds); err != nil {\n\t\treturn fmt.Errorf(\"credentials file at %s is not valid JSON: %w\\nRun 'claude /login' to re-authenticate.\", path, err)\n\t}\n\tif extractClaudeAccessToken(creds) == \"\" {\n\t\treturn fmt.Errorf(\"no access token found in %s\\nRun 'claude /login' to re-authenticate.\", path)\n\t}\n\treturn nil\n}\n\n// validateSubscriptionCredentials verifies the user has at least one credential\n// path wired up for an OAuth subscription handler. The handlers themselves\n// (config/_handler.py) walk the same resolution order at runtime:\n//\n// 1. _ACCESS_TOKEN env (pre-extracted Bearer)\n// 2. _SESSION_TOKEN / _SESSION_COOKIES / _REFRESH_TOKEN env\n// 3. ~/.config//tokens.json on disk\n//\n// We don't validate token shape — providers ship them in many formats and shapes\n// drift across versions. We only catch the \"toggled on in onboard but never\n// pasted a token\" case before the first agent prompt fails opaquely.\nfunc validateSubscriptionCredentials(env map[string]string, sub subscriptionMethod) error {\n\tfor _, name := range sub.TokenEnvs {\n\t\tif strings.TrimSpace(env[name]) != \"\" {\n\t\t\treturn nil\n\t\t}\n\t}\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"locate home directory: %w\", err)\n\t}\n\tpath := filepath.Join(home, \".config\", sub.ConfigDir, \"tokens.json\")\n\tif _, err := os.Stat(path); err == nil {\n\t\treturn nil\n\t}\n\thints := strings.Join(sub.TokenEnvs, \" or \")\n\treturn fmt.Errorf(\n\t\t\"%s subscription token not configured.\\n\"+\n\t\t\t\"Provide one of:\\n\"+\n\t\t\t\" - %s env var\\n\"+\n\t\t\t\" - %s on disk\\n\"+\n\t\t\t\"Run 'decepticon onboard --reset' to (re)configure interactively.\",\n\t\tsub.Label, hints, path,\n\t)\n}\n\n// extractClaudeAccessToken walks the credentials JSON in the same resolution order as\n// the LiteLLM handler (config/claude_code_handler.py): current nested format first,\n// then legacy top-level keys. Returns \"\" if no usable token is present.\nfunc extractClaudeAccessToken(creds map[string]any) string {\n\tif oauth, ok := creds[\"claudeAiOauth\"].(map[string]any); ok {\n\t\tif tok, _ := oauth[\"accessToken\"].(string); tok != \"\" {\n\t\t\treturn tok\n\t\t}\n\t}\n\tif tok, _ := creds[\"accessToken\"].(string); tok != \"\" {\n\t\treturn tok\n\t}\n\tif tok, _ := creds[\"oauthToken\"].(string); tok != \"\" {\n\t\treturn tok\n\t}\n\treturn \"\"\n}\n\n// AppendEnvLine appends a KEY=VALUE line to an existing .env file.\nfunc AppendEnvLine(path, key, value string) error {\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\t_, err = fmt.Fprintf(f, \"\\n%s=%s\\n\", key, value)\n\treturn err\n}\n\n// Get returns a config value with a fallback default.\nfunc Get(env map[string]string, key, fallback string) string {\n\tif val, ok := env[key]; ok && val != \"\" {\n\t\treturn val\n\t}\n\treturn fallback\n}\n"}, "26c5162295d35bcc61366533f31533c0d671ede8": {"t": 1785755091.0594795, "n": 16488, "h": "e6e2e6132034e768", "c": "package config\n\nimport (\n\t\"bufio\"\n\t_ \"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n//go:embed env.example\nvar EnvTemplate string\n\nconst (\n\tDefaultHome = \".decepticon\"\n\tEnvFileName = \".env\"\n\tEnvExampleName = \".env.example\"\n\tPlaceholderSuffix = \"-key-here\"\n)\n\n// Config holds the Decepticon launcher configuration.\ntype Config struct {\n\tHome string\n\tEnv map[string]string\n}\n\n// DecepticonHome returns the resolved DECEPTICON_HOME path.\nfunc DecepticonHome() string {\n\tif h := os.Getenv(\"DECEPTICON_HOME\"); h != \"\" {\n\t\treturn h\n\t}\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn filepath.Join(\"~\", DefaultHome)\n\t}\n\treturn filepath.Join(home, DefaultHome)\n}\n\n// EnvPath returns the full path to the .env file.\nfunc EnvPath() string {\n\treturn filepath.Join(DecepticonHome(), EnvFileName)\n}\n\n// EnvExists checks whether .env exists.\nfunc EnvExists() bool {\n\t_, err := os.Stat(EnvPath())\n\treturn err == nil\n}\n\n// LoadEnv reads a .env file and returns key-value pairs.\n// It handles comments (#), empty lines, and optional quoting.\nfunc LoadEnv(path string) (map[string]string, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"open env file: %w\", err)\n\t}\n\tdefer f.Close()\n\n\tenv := make(map[string]string)\n\tscanner := bufio.NewScanner(f)\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\t\tkey, val, ok := parseEnvLine(line)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tenv[key] = val\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"read env file: %w\", err)\n\t}\n\treturn env, nil\n}\n\n// parseEnvLine splits \"KEY=VALUE\" handling optional quotes.\nfunc parseEnvLine(line string) (key, val string, ok bool) {\n\tidx := strings.IndexByte(line, '=')\n\tif idx < 1 {\n\t\treturn \"\", \"\", false\n\t}\n\tkey = strings.TrimSpace(line[:idx])\n\tval = strings.TrimSpace(line[idx+1:])\n\t// Strip surrounding quotes\n\tif len(val) >= 2 {\n\t\tif (val[0] == '\"' && val[len(val)-1] == '\"') ||\n\t\t\t(val[0] == '\\'' && val[len(val)-1] == '\\'') {\n\t\t\tval = val[1 : len(val)-1]\n\t\t}\n\t}\n\treturn key, val, true\n}\n\n// WriteEnvFromEmbed writes key-value pairs into a .env file using the embedded template.\nfunc WriteEnvFromEmbed(outputPath string, values map[string]string) error {\n\treturn writeEnvFromString(EnvTemplate, outputPath, values)\n}\n\n// WriteEnv writes key-value pairs into a .env file using a template file.\nfunc WriteEnv(templatePath, outputPath string, values map[string]string) error {\n\ttmpl, err := os.ReadFile(templatePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"read template: %w\", err)\n\t}\n\treturn writeEnvFromString(string(tmpl), outputPath, values)\n}\n\nfunc writeEnvFromString(tmpl string, outputPath string, values map[string]string) error {\n\tlines := strings.Split(tmpl, \"\\n\")\n\tvar out []string\n\tfor _, line := range lines {\n\t\ttrimmed := strings.TrimSpace(line)\n\t\t// Skip commented lines — keep as-is\n\t\tif trimmed == \"\" || strings.HasPrefix(trimmed, \"#\") {\n\t\t\tout = append(out, line)\n\t\t\tcontinue\n\t\t}\n\t\tkey, _, ok := parseEnvLine(trimmed)\n\t\tif !ok {\n\t\t\tout = append(out, line)\n\t\t\tcontinue\n\t\t}\n\t\tif newVal, exists := values[key]; exists {\n\t\t\tout = append(out, key+\"=\"+newVal)\n\t\t} else {\n\t\t\tout = append(out, line)\n\t\t}\n\t}\n\n\tif err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {\n\t\treturn fmt.Errorf(\"create directory: %w\", err)\n\t}\n\treturn os.WriteFile(outputPath, []byte(strings.Join(out, \"\\n\")), 0o600)\n}\n\n// APIKeyNames lists the API key environment variable names to check.\n// Order matters: a valid key in this list satisfies the \"at least one\n// credential\" startup gate. Keep in sync with keyFormatRules below and\n// with decepticon/llm/factory.py::_API_METHOD_ENV.\nvar APIKeyNames = []string{\n\t\"ANTHROPIC_API_KEY\",\n\t\"OPENAI_API_KEY\",\n\t\"GEMINI_API_KEY\",\n\t\"MINIMAX_API_KEY\",\n\t\"OPENROUTER_API_KEY\",\n\t\"NVIDIA_API_KEY\",\n\t\"DEEPSEEK_API_KEY\",\n\t\"XAI_API_KEY\",\n\t\"MISTRAL_API_KEY\",\n}\n\n// IsPlaceholder checks if a value looks like a placeholder.\nfunc IsPlaceholder(val string) bool {\n\treturn strings.HasSuffix(val, PlaceholderSuffix) || val == \"\"\n}\n\n// keyFormatRules maps an API key env var to its expected prefix and human-readable hint.\n// Format checks are intentionally lenient — providers occasionally evolve key shapes\n// (OpenAI shipped sk-proj-* in 2024, Anthropic sk-ant-api03-* etc.). The check only\n// rejects values that are obviously malformed (typos, missing prefix).\n//\n// An empty Prefix means \"no fixed shape, skip the prefix check\" (only the\n// length floor still applies). MiniMax/Mistral keys ship without a\n// universal prefix and would otherwise be rejected when the user pastes\n// the right key.\nvar keyFormatRules = map[string]struct {\n\tPrefix string\n\tHint string\n}{\n\t\"ANTHROPIC_API_KEY\": {Prefix: \"sk-\", Hint: \"Anthropic keys start with 'sk-'\"},\n\t\"OPENAI_API_KEY\": {Prefix: \"sk-\", Hint: \"OpenAI keys start with 'sk-'\"},\n\t\"GEMINI_API_KEY\": {Prefix: \"AIza\", Hint: \"Gemini keys start with 'AIza'\"},\n\t\"OPENROUTER_API_KEY\": {Prefix: \"sk-or-\", Hint: \"OpenRouter keys start with 'sk-or-'\"},\n\t\"NVIDIA_API_KEY\": {Prefix: \"nvapi-\", Hint: \"NVIDIA keys start with 'nvapi-'\"},\n\t\"DEEPSEEK_API_KEY\": {Prefix: \"sk-\", Hint: \"DeepSeek keys start with 'sk-'\"},\n\t\"XAI_API_KEY\": {Prefix: \"xai-\", Hint: \"xAI keys start with 'xai-'\"},\n\t\"MINIMAX_API_KEY\": {Prefix: \"\", Hint: \"\"},\n\t\"MISTRAL_API_KEY\": {Prefix: \"\", Hint: \"\"},\n}\n\n// validateKeyFormat returns an empty string if the key looks valid, or a reason if not.\nfunc validateKeyFormat(name, val string) string {\n\tif len(val) < 20 {\n\t\treturn \"value is too short to be a valid API key\"\n\t}\n\tif rule, ok := keyFormatRules[name]; ok {\n\t\t// Empty Prefix → provider has no fixed prefix, length floor is the\n\t\t// only signal. Skip the prefix check rather than rejecting valid keys.\n\t\tif rule.Prefix != \"\" && !strings.HasPrefix(val, rule.Prefix) {\n\t\t\treturn rule.Hint\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ValidateAPIKeys checks that at least one API key is set with a valid format.\n// Returns a fatal error listing both unset keys and any format problems found.\nfunc ValidateAPIKeys(env map[string]string) error {\n\tvar validNames []string\n\tinvalidReasons := make(map[string]string)\n\n\tfor _, name := range APIKeyNames {\n\t\tval := env[name]\n\t\tif val == \"\" || IsPlaceholder(val) {\n\t\t\tcontinue\n\t\t}\n\t\tif reason := validateKeyFormat(name, val); reason != \"\" {\n\t\t\tinvalidReasons[name] = reason\n\t\t\tcontinue\n\t\t}\n\t\tvalidNames = append(validNames, name)\n\t}\n\n\tif len(validNames) > 0 {\n\t\treturn nil\n\t}\n\n\tvar msg strings.Builder\n\tmsg.WriteString(\"no valid API key found.\")\n\tif len(invalidReasons) > 0 {\n\t\tmsg.WriteString(\" Detected malformed key(s):\")\n\t\tfor name, reason := range invalidReasons {\n\t\t\tmsg.WriteString(fmt.Sprintf(\"\\n %s: %s\", name, reason))\n\t\t}\n\t}\n\tmsg.WriteString(\"\\nRun 'decepticon onboard --reset' to reconfigure credentials.\")\n\treturn fmt.Errorf(\"%s\", msg.String())\n}\n\n// subscriptionMethod groups the env signal + credential layout for one\n// OAuth subscription handler so we don't repeat the validation logic.\n// Resolution order matches each handler in config/*_handler.py exactly:\n//\n//\t1. _ACCESS_TOKEN — pre-extracted Bearer\n//\t2. _SESSION_TOKEN — browser session cookie value\n//\t3. ~/.config//tokens.json on disk\n//\n// Two providers diverge slightly:\n// - Gemini Advanced ships a multi-cookie value (GEMINI_SESSION_COOKIES)\n// instead of a single session token. accept either env name.\n// - Copilot Pro uses a refresh-token rotation (COPILOT_REFRESH_TOKEN)\n// instead of a session cookie. Same fall-through, different env name.\ntype subscriptionMethod struct {\n\tToggle string // DECEPTICON_AUTH_ boolean enabling this path\n\tTokenEnvs []string // env vars that satisfy the path on their own\n\tConfigDir string // ~/.config//tokens.json fallback\n\tLabel string // human name for error messages\n}\n\nvar oauthSubscriptions = map[string]subscriptionMethod{\n\t\"chatgpt\": {\n\t\tToggle: \"DECEPTICON_AUTH_CHATGPT\",\n\t\tTokenEnvs: []string{\"CHATGPT_ACCESS_TOKEN\", \"CHATGPT_SESSION_TOKEN\"},\n\t\tConfigDir: \"chatgpt\",\n\t\tLabel: \"ChatGPT\",\n\t},\n\t\"gemini\": {\n\t\tToggle: \"DECEPTICON_AUTH_GEMINI\",\n\t\tTokenEnvs: []string{\"GEMINI_ACCESS_TOKEN\", \"GEMINI_SESSION_COOKIES\"},\n\t\tConfigDir: \"gemini\",\n\t\tLabel: \"Gemini Advanced\",\n\t},\n\t\"copilot\": {\n\t\tToggle: \"DECEPTICON_AUTH_COPILOT\",\n\t\tTokenEnvs: []string{\"COPILOT_ACCESS_TOKEN\", \"COPILOT_REFRESH_TOKEN\"},\n\t\tConfigDir: \"copilot\",\n\t\tLabel: \"Copilot Pro\",\n\t},\n\t\"grok\": {\n\t\tToggle: \"DECEPTICON_AUTH_GROK\",\n\t\tTokenEnvs: []string{\"GROK_ACCESS_TOKEN\", \"GROK_SESSION_TOKEN\"},\n\t\tConfigDir: \"grok\",\n\t\tLabel: \"SuperGrok\",\n\t},\n\t\"perplexity\": {\n\t\tToggle: \"DECEPTICON_AUTH_PERPLEXITY\",\n\t\tTokenEnvs: []string{\"PERPLEXITY_ACCESS_TOKEN\", \"PERPLEXITY_SESSION_TOKEN\"},\n\t\tConfigDir: \"perplexity\",\n\t\tLabel: \"Perplexity Pro\",\n\t},\n}\n\n// ValidateAuth ensures at least one valid AuthMethod is configured.\n//\n// OAuth paths:\n// - DECEPTICON_AUTH_CLAUDE_CODE=true requires a parseable\n// ~/.claude/.credentials.json. LiteLLM mounts that file read-only.\n// - DECEPTICON_AUTH_=true (CHATGPT, GEMINI, COPILOT, GROK,\n// PERPLEXITY) is satisfied by a token env var or a tokens.json\n// file under ~/.config//. See subscriptionMethod above.\n//\n// Local LLM path: ollama_local in DECEPTICON_AUTH_PRIORITY (or any\n// OLLAMA_API_BASE configured) is treated as a valid credential. Ollama\n// reachability is probed separately at startup (start.go).\n//\n// API path: at least one configured provider key (ANTHROPIC / OPENAI /\n// GEMINI / MINIMAX / OPENROUTER / NVIDIA / DEEPSEEK / XAI / MISTRAL)\n// must be non-placeholder and well-formed.\n//\n// At least one path must succeed. When OAuth is requested and its\n// credentials are broken, the other paths are checked as fallbacks;\n// if all fail, the OAuth error is surfaced first because that was the\n// user's explicit choice.\nfunc ValidateAuth(env map[string]string) error {\n\tclaudeOAuth := isTruthy(Get(env, \"DECEPTICON_AUTH_CLAUDE_CODE\", \"\"))\n\tollamaErr := validateOllamaCredentials(env)\n\tapiErr := ValidateAPIKeys(env)\n\n\tif claudeOAuth {\n\t\tif err := validateClaudeCredentials(); err == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\tfor _, sub := range oauthSubscriptions {\n\t\tif !isTruthy(Get(env, sub.Toggle, \"\")) {\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateSubscriptionCredentials(env, sub); err == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\tif ollamaErr == nil {\n\t\treturn nil\n\t}\n\tif apiErr == nil {\n\t\treturn nil\n\t}\n\n\t// All paths failed — surface the user's explicit choice first.\n\tif claudeOAuth {\n\t\treturn validateClaudeCredentials()\n\t}\n\tfor _, key := range []string{\"chatgpt\", \"gemini\", \"copilot\", \"grok\", \"perplexity\"} {\n\t\tsub := oauthSubscriptions[key]\n\t\tif isTruthy(Get(env, sub.Toggle, \"\")) {\n\t\t\treturn validateSubscriptionCredentials(env, sub)\n\t\t}\n\t}\n\tif hasOllamaSelected(env) {\n\t\treturn ollamaErr\n\t}\n\treturn apiErr\n}\n\n// hasOllamaSelected returns true when the user has explicitly opted into\n// the local-Ollama auth method (via the priority list or by setting\n// OLLAMA_API_BASE without any other API key).\nfunc hasOllamaSelected(env map[string]string) bool {\n\tpriority := strings.ToLower(strings.TrimSpace(env[\"DECEPTICON_AUTH_PRIORITY\"]))\n\tfor _, m := range strings.Split(priority, \",\") {\n\t\tif strings.TrimSpace(m) == \"ollama_local\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn env[\"OLLAMA_API_BASE\"] != \"\"\n}\n\n// validateOllamaCredentials accepts the Ollama path when the user has\n// either listed ``ollama_local`` in DECEPTICON_AUTH_PRIORITY or set\n// ``OLLAMA_API_BASE`` directly. Ollama itself has no API key — the\n// only required signal is the base URL pointing at a running instance.\n//\n// We don't probe the URL here; the launcher runs on the host while the\n// LiteLLM container talks to the URL from inside Docker, so a host-side\n// reachability check would lie when the user (correctly) wired up\n// host.docker.internal-style addressing.\nfunc validateOllamaCredentials(env map[string]string) error {\n\tif !hasOllamaSelected(env) {\n\t\treturn fmt.Errorf(\"ollama not configured\")\n\t}\n\tbase := strings.TrimSpace(env[\"OLLAMA_API_BASE\"])\n\tif base == \"\" {\n\t\treturn fmt.Errorf(\n\t\t\t\"ollama_local selected but OLLAMA_API_BASE is empty.\\n\" +\n\t\t\t\t\"Set OLLAMA_API_BASE=http://host.docker.internal:11434 (or your Ollama URL) \" +\n\t\t\t\t\"in ~/.decepticon/.env, or run 'decepticon onboard --reset' to reconfigure.\",\n\t\t)\n\t}\n\treturn nil\n}\n\nfunc isTruthy(s string) bool {\n\tswitch strings.ToLower(strings.TrimSpace(s)) {\n\tcase \"true\", \"1\", \"yes\", \"on\":\n\t\treturn true\n\t}\n\treturn false\n}\n\n// validateClaudeCredentials verifies ~/.claude/.credentials.json exists, is a regular\n// file, parses as JSON, and carries an access token in one of the shapes the LiteLLM\n// claude_code_handler accepts (claudeAiOauth.accessToken, top-level accessToken, or\n// legacy oauthToken). Compose mounts this path into the LiteLLM container; if it's\n// missing or empty, authentication fails opaquely on the first prompt instead of here.\nfunc validateClaudeCredentials() error {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"locate home directory: %w\", err)\n\t}\n\tpath := filepath.Join(home, \".claude\", \".credentials.json\")\n\tinfo, err := os.Stat(path)\n\tif os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"Claude Code credentials not found at %s\\nRun 'claude /login' (Claude Code CLI) to authenticate, then retry.\", path)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"stat %s: %w\", path, err)\n\t}\n\tif info.IsDir() {\n\t\treturn fmt.Errorf(\"expected credentials file at %s but found a directory.\\nRemove it and run 'claude /login' to re-authenticate.\", path)\n\t}\n\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"read %s: %w\\nCheck file permissions and re-run.\", path, err)\n\t}\n\tvar creds map[string]any\n\tif err := json.Unmarshal(raw, &creds); err != nil {\n\t\treturn fmt.Errorf(\"credentials file at %s is not valid JSON: %w\\nRun 'claude /login' to re-authenticate.\", path, err)\n\t}\n\tif extractClaudeAccessToken(creds) == \"\" {\n\t\treturn fmt.Errorf(\"no access token found in %s\\nRun 'claude /login' to re-authenticate.\", path)\n\t}\n\treturn nil\n}\n\n// validateSubscriptionCredentials verifies the user has at least one credential\n// path wired up for an OAuth subscription handler. The handlers themselves\n// (config/_handler.py) walk the same resolution order at runtime:\n//\n// 1. _ACCESS_TOKEN env (pre-extracted Bearer)\n// 2. _SESSION_TOKEN / _SESSION_COOKIES / _REFRESH_TOKEN env\n// 3. ~/.config//tokens.json on disk\n//\n// We don't validate token shape — providers ship them in many formats and shapes\n// drift across versions. We only catch the \"toggled on in onboard but never\n// pasted a token\" case before the first agent prompt fails opaquely.\nfunc validateSubscriptionCredentials(env map[string]string, sub subscriptionMethod) error {\n\tfor _, name := range sub.TokenEnvs {\n\t\tif strings.TrimSpace(env[name]) != \"\" {\n\t\t\treturn nil\n\t\t}\n\t}\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"locate home directory: %w\", err)\n\t}\n\tpath := filepath.Join(home, \".config\", sub.ConfigDir, \"tokens.json\")\n\tif _, err := os.Stat(path); err == nil {\n\t\treturn nil\n\t}\n\thints := strings.Join(sub.TokenEnvs, \" or \")\n\treturn fmt.Errorf(\n\t\t\"%s subscription token not configured.\\n\"+\n\t\t\t\"Provide one of:\\n\"+\n\t\t\t\" - %s env var\\n\"+\n\t\t\t\" - %s on disk\\n\"+\n\t\t\t\"Run 'decepticon onboard --reset' to (re)configure interactively.\",\n\t\tsub.Label, hints, path,\n\t)\n}\n\n// extractClaudeAccessToken walks the credentials JSON in the same resolution order as\n// the LiteLLM handler (config/claude_code_handler.py): current nested format first,\n// then legacy top-level keys. Returns \"\" if no usable token is present.\nfunc extractClaudeAccessToken(creds map[string]any) string {\n\tif oauth, ok := creds[\"claudeAiOauth\"].(map[string]any); ok {\n\t\tif tok, _ := oauth[\"accessToken\"].(string); tok != \"\" {\n\t\t\treturn tok\n\t\t}\n\t}\n\tif tok, _ := creds[\"accessToken\"].(string); tok != \"\" {\n\t\treturn tok\n\t}\n\tif tok, _ := creds[\"oauthToken\"].(string); tok != \"\" {\n\t\treturn tok\n\t}\n\treturn \"\"\n}\n\n// AppendEnvLine appends a KEY=VALUE line to an existing .env file.\nfunc AppendEnvLine(path, key, value string) error {\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\t_, err = fmt.Fprintf(f, \"\\n%s=%s\\n\", key, value)\n\treturn err\n}\n\n// Get returns a config value with a fallback default.\nfunc Get(env map[string]string, key, fallback string) string {\n\tif val, ok := env[key]; ok && val != \"\" {\n\t\treturn val\n\t}\n\treturn fallback\n}\n"}}}}