From 5a4b2c8e25337c6ec013baf4295dbb520712e501 Mon Sep 17 00:00:00 2001 From: z23cc Date: Thu, 31 Jul 2025 16:07:41 +0800 Subject: [PATCH] feat: improve model configuration and settings management (#59) * feat: improve model configuration and settings management - Refactor model configuration system with unified settings approach - Add support for user-level default models and project-level current models - Implement automatic saving of API key and base URL from command line arguments - Remove model descriptions for cleaner UI and easier extensibility - Fix commander.js subcommand argument parsing with fallback mechanism - Improve configuration file structure and priority handling - Add comprehensive model configuration utilities in src/utils/model-config.ts Configuration priority: project current model > user default model > system default Both main command and git subcommand now save credentials automatically * refactor: unify settings management - Add SettingsManager singleton for user/project settings - Eliminate duplicate settings code across files - Clear separation: user settings vs project settings * docs: improve README clarity and organization - Simplify setup section with quick start approach - Consolidate configuration info into unified section - Add clear explanation of user vs project settings - Document OpenAI-compatible API support - Remove duplicate model configuration sections * bump sdk --------- Co-authored-by: Ismail Pelaseyed --- README.md | 91 +++++++- package-lock.json | 4 +- package.json | 2 +- src/agent/grok-agent.ts | 5 +- src/hooks/use-input-handler.ts | 35 ++- src/index.ts | 104 ++++----- src/mcp/config.ts | 83 +++----- src/ui/components/api-key-input.tsx | 37 +--- src/ui/components/model-selection.tsx | 4 - src/utils/model-config.ts | 56 +++++ src/utils/settings-manager.ts | 294 ++++++++++++++++++++++++++ src/utils/settings.ts | 70 +----- 12 files changed, 534 insertions(+), 251 deletions(-) create mode 100644 src/utils/model-config.ts create mode 100644 src/utils/settings-manager.ts diff --git a/README.md b/README.md index b09d01c..b6f1949 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Create `~/.grok/user-settings.json`: ### Custom Base URL (Optional) -You can configure a custom Grok API endpoint (choose one method): +By default, the CLI uses `https://api.x.ai/v1` as the Grok API endpoint. You can configure a custom endpoint if needed (choose one method): **Method 1: Environment Variable** ```bash @@ -75,7 +75,7 @@ export GROK_BASE_URL=https://your-custom-endpoint.com/v1 **Method 2: Command Line Flag** ```bash -grok --api-key your_api_key_here --baseurl https://your-custom-endpoint.com/v1 +grok --api-key your_api_key_here --base-url https://your-custom-endpoint.com/v1 ``` **Method 3: User Settings File** @@ -87,6 +87,89 @@ Add to `~/.grok/user-settings.json`: } ``` +## Configuration Files + +Grok CLI uses two types of configuration files to manage settings: + +### User-Level Settings (`~/.grok/user-settings.json`) + +This file stores **global settings** that apply across all projects. These settings rarely change and include: + +- **API Key**: Your Grok API key +- **Base URL**: Custom API endpoint (if needed) +- **Default Model**: Your preferred model (e.g., `grok-4-latest`) +- **Available Models**: List of models you can use + +**Example:** +```json +{ + "apiKey": "your_api_key_here", + "baseURL": "https://api.x.ai/v1", + "defaultModel": "grok-4-latest", + "models": [ + "grok-4-latest", + "grok-3-latest", + "grok-3-fast", + "grok-3-mini-fast" + ] +} +``` + +### Project-Level Settings (`.grok/settings.json`) + +This file stores **project-specific settings** in your current working directory. It includes: + +- **Current Model**: The model currently in use for this project +- **MCP Servers**: Model Context Protocol server configurations + +**Example:** +```json +{ + "model": "grok-3-fast", + "mcpServers": { + "linear": { + "name": "linear", + "transport": "stdio", + "command": "npx", + "args": ["@linear/mcp-server"] + } + } +} +``` + +### How It Works + +1. **Global Defaults**: User-level settings provide your default preferences +2. **Project Override**: Project-level settings override defaults for specific projects +3. **Directory-Specific**: When you change directories, project settings are loaded automatically +4. **Fallback Logic**: Project model → User default model → System default (`grok-4-latest`) + +This means you can have different models for different projects while maintaining consistent global settings like your API key. + +### Using Other API Providers + +**Important**: Grok CLI uses **OpenAI-compatible APIs**. You can use any provider that implements the OpenAI chat completions standard. + +**Popular Providers**: +- **X.AI (Grok)**: `https://api.x.ai/v1` (default) +- **OpenAI**: `https://api.openai.com/v1` +- **OpenRouter**: `https://openrouter.ai/api/v1` +- **Groq**: `https://api.groq.com/openai/v1` + +**Example with OpenRouter**: +```json +{ + "apiKey": "your_openrouter_key", + "baseURL": "https://openrouter.ai/api/v1", + "defaultModel": "anthropic/claude-3.5-sonnet", + "models": [ + "anthropic/claude-3.5-sonnet", + "openai/gpt-4o", + "meta-llama/llama-3.1-70b-instruct" + ] +} +``` + ## Usage ### Interactive Mode @@ -143,11 +226,11 @@ Add to `~/.grok/user-settings.json`: ```json { "apiKey": "your_api_key_here", - "model": "grok-4-latest" + "defaultModel": "grok-4-latest" } ``` -Priority order: `--model` flag > `GROK_MODEL` environment variable > user settings > default (grok-4-latest) +**Model Priority**: `--model` flag > `GROK_MODEL` environment variable > user default model > system default (grok-4-latest) ### Command Line Options diff --git a/package-lock.json b/package-lock.json index ef50d02..fcd126f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vibe-kit/grok-cli", - "version": "0.0.12", + "version": "0.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vibe-kit/grok-cli", - "version": "0.0.12", + "version": "0.0.17", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.0", diff --git a/package.json b/package.json index 538a865..d38578f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vibe-kit/grok-cli", - "version": "0.0.17", + "version": "0.0.18", "description": "An open-source AI agent that brings the power of Grok directly into your terminal.", "main": "dist/index.js", "bin": { diff --git a/src/agent/grok-agent.ts b/src/agent/grok-agent.ts index 6fd049b..55f3f61 100644 --- a/src/agent/grok-agent.ts +++ b/src/agent/grok-agent.ts @@ -18,7 +18,7 @@ import { ToolResult } from "../types"; import { EventEmitter } from "events"; import { createTokenCounter, TokenCounter } from "../utils/token-counter"; import { loadCustomInstructions } from "../utils/custom-instructions"; -import { getSetting } from "../utils/settings"; +import { getSettingsManager } from "../utils/settings-manager"; export interface ChatEntry { type: "user" | "assistant" | "tool_result" | "tool_call"; @@ -54,7 +54,8 @@ export class GrokAgent extends EventEmitter { constructor(apiKey: string, baseURL?: string, model?: string) { super(); - const savedModel = getSetting("model"); + const manager = getSettingsManager(); + const savedModel = manager.getCurrentModel(); const modelToUse = model || savedModel || "grok-4-latest"; this.grokClient = new GrokClient(apiKey, modelToUse, baseURL); this.textEditor = new TextEditorTool(); diff --git a/src/hooks/use-input-handler.ts b/src/hooks/use-input-handler.ts index 0ac094d..6a95227 100644 --- a/src/hooks/use-input-handler.ts +++ b/src/hooks/use-input-handler.ts @@ -1,9 +1,10 @@ -import { useState, useRef } from "react"; +import { useState, useMemo } from "react"; import { useInput } from "ink"; import { GrokAgent, ChatEntry } from "../agent/grok-agent"; import { ConfirmationService } from "../utils/confirmation-service"; -import { updateSetting } from "../utils/settings"; + import { filterCommandSuggestions } from "../ui/components/command-suggestions"; +import { loadModelConfig, updateCurrentModel } from "../utils/model-config"; interface UseInputHandlerProps { agent: GrokAgent; @@ -26,7 +27,6 @@ interface CommandSuggestion { interface ModelOption { model: string; - description: string; } export function useInputHandler({ @@ -62,15 +62,10 @@ export function useInputHandler({ { command: "/exit", description: "Exit the application" }, ]; - const availableModels: ModelOption[] = [ - { - model: "grok-4-latest", - description: "Latest Grok-4 model (most capable)", - }, - { model: "grok-3-latest", description: "Latest Grok-3 model" }, - { model: "grok-3-fast", description: "Fast Grok-3 variant" }, - { model: "grok-3-mini-fast", description: "Fastest Grok-3 variant" }, - ]; + // Load models from configuration with fallback to defaults + const availableModels: ModelOption[] = useMemo(() => { + return loadModelConfig(); // Return directly, interface already matches + }, []); const handleDirectCommand = async (input: string): Promise => { const trimmedInput = input.trim(); @@ -102,24 +97,27 @@ export function useInputHandler({ Built-in Commands: /clear - Clear chat history /help - Show this help - /models - Switch Grok models + /models - Switch between available models /exit - Exit application exit, quit - Exit application - + Git Commands: /commit-and-push - AI-generated commit + push to remote - + Keyboard Shortcuts: Shift+Tab - Toggle auto-edit mode (bypass confirmations) Direct Commands (executed immediately): ls [path] - List directory contents - pwd - Show current directory + pwd - Show current directory cd - Change directory cat - View file contents mkdir - Create directory touch - Create empty file +Model Configuration: + Edit ~/.grok/models.json to add custom models (Claude, GPT, Gemini, etc.) + For complex operations, just describe what you want in natural language. Examples: "edit package.json and add a new script" @@ -150,7 +148,7 @@ Examples: if (modelNames.includes(modelArg)) { agent.setModel(modelArg); - updateSetting("model", modelArg); + updateCurrentModel(modelArg); // Update project current model const confirmEntry: ChatEntry = { type: "assistant", content: `✓ Switched to model: ${modelArg}`, @@ -172,6 +170,7 @@ Available models: ${modelNames.join(", ")}`, return true; } + if (trimmedInput === "/commit-and-push") { const userEntry: ChatEntry = { type: "user", @@ -665,7 +664,7 @@ Respond with ONLY the commit message, no additional text.`; if (key.tab || key.return) { const selectedModel = availableModels[selectedModelIndex]; agent.setModel(selectedModel.model); - updateSetting("model", selectedModel.model); + updateCurrentModel(selectedModel.model); // Update project current model const confirmEntry: ChatEntry = { type: "assistant", content: `✓ Switched to model: ${selectedModel.model}`, diff --git a/src/index.ts b/src/index.ts index ed6eb6b..6640e35 100755 --- a/src/index.ts +++ b/src/index.ts @@ -6,10 +6,7 @@ import { program } from "commander"; import * as dotenv from "dotenv"; import { GrokAgent } from "./agent/grok-agent"; import ChatInterface from "./ui/components/chat-interface"; -import { getSetting, loadSettings } from "./utils/settings"; -import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; +import { getSettingsManager } from "./utils/settings-manager"; import { ConfirmationService } from "./utils/confirmation-service"; import { createMCPCommand } from "./commands/mcp"; import type { ChatCompletionMessageParam } from "openai/resources/chat"; @@ -47,27 +44,12 @@ process.on("unhandledRejection", (reason, promise) => { process.exit(1); }); -// Ensure user .grok directory exists with default settings +// Ensure user settings are initialized function ensureUserSettingsDirectory(): void { try { - const homeDir = os.homedir(); - const grokDir = path.join(homeDir, ".grok"); - const settingsFile = path.join(grokDir, "user-settings.json"); - - // Create .grok directory if it doesn't exist - if (!fs.existsSync(grokDir)) { - fs.mkdirSync(grokDir, { recursive: true }); - } - - // Create default user-settings.json if it doesn't exist - if (!fs.existsSync(settingsFile)) { - const defaultSettings = { - apiKey: "", - baseURL: "", - defaultModel: "grok-4-latest", - }; - fs.writeFileSync(settingsFile, JSON.stringify(defaultSettings, null, 2)); - } + const manager = getSettingsManager(); + // This will create default settings if they don't exist + manager.loadUserSettings(); } catch (error) { // Silently ignore errors during setup } @@ -75,50 +57,33 @@ function ensureUserSettingsDirectory(): void { // Load API key from user settings if not in environment function loadApiKey(): string | undefined { - // First check environment variables - let apiKey = process.env.GROK_API_KEY; - - if (!apiKey) { - // Try to load from user settings file - try { - ensureUserSettingsDirectory(); - const homeDir = os.homedir(); - const settingsFile = path.join(homeDir, ".grok", "user-settings.json"); - - if (fs.existsSync(settingsFile)) { - const settings = JSON.parse(fs.readFileSync(settingsFile, "utf8")); - apiKey = settings.apiKey; - } - } catch (error) { - // Ignore errors, apiKey will remain undefined - } - } - - return apiKey; + const manager = getSettingsManager(); + return manager.getApiKey(); } // Load base URL from user settings if not in environment -function loadBaseURL(): string | undefined { - // First check environment variables - let baseURL = process.env.GROK_BASE_URL; +function loadBaseURL(): string { + const manager = getSettingsManager(); + return manager.getBaseURL(); +} - if (!baseURL) { - // Try to load from user settings file - try { - ensureUserSettingsDirectory(); - const homeDir = os.homedir(); - const settingsFile = path.join(homeDir, ".grok", "user-settings.json"); +// Save command line settings to user settings file +async function saveCommandLineSettings(apiKey?: string, baseURL?: string): Promise { + try { + const manager = getSettingsManager(); - if (fs.existsSync(settingsFile)) { - const settings = JSON.parse(fs.readFileSync(settingsFile, "utf8")); - baseURL = settings.baseURL; - } - } catch (error) { - // Ignore errors, baseURL will remain undefined + // Update with command line values + if (apiKey) { + manager.updateUserSetting('apiKey', apiKey); + console.log("✅ API key saved to ~/.grok/user-settings.json"); } + if (baseURL) { + manager.updateUserSetting('baseURL', baseURL); + console.log("✅ Base URL saved to ~/.grok/user-settings.json"); + } + } catch (error) { + console.warn("⚠️ Could not save settings to file:", error instanceof Error ? error.message : "Unknown error"); } - - return baseURL; } // Load model from user settings if not in environment @@ -127,10 +92,10 @@ function loadModel(): string | undefined { let model = process.env.GROK_MODEL; if (!model) { - // Try to load from local settings file + // Use the unified model loading from settings manager try { - const settings = loadSettings(); - model = settings.model; + const manager = getSettingsManager(); + model = manager.getCurrentModel(); } catch (error) { // Ignore errors, model will remain undefined } @@ -383,6 +348,11 @@ program process.exit(1); } + // Save API key and base URL to user settings if provided via command line + if (options.apiKey || options.baseUrl) { + await saveCommandLineSettings(options.apiKey, options.baseUrl); + } + // Headless mode: process prompt and exit if (options.prompt) { await processPromptHeadless(options.prompt, apiKey, baseURL, model); @@ -392,6 +362,9 @@ program // Interactive mode: launch UI const agent = new GrokAgent(apiKey, baseURL, model); console.log("🤖 Starting Grok CLI Conversational Assistant...\n"); + + ensureUserSettingsDirectory(); + render(React.createElement(ChatInterface, { agent })); } catch (error: any) { console.error("❌ Error initializing Grok CLI:", error.message); @@ -443,6 +416,11 @@ gitCommand process.exit(1); } + // Save API key and base URL to user settings if provided via command line + if (options.apiKey || options.baseUrl) { + await saveCommandLineSettings(options.apiKey, options.baseUrl); + } + await handleCommitAndPushHeadless(apiKey, baseURL, model); } catch (error: any) { console.error("❌ Error during git commit-and-push:", error.message); diff --git a/src/mcp/config.ts b/src/mcp/config.ts index f12ab3c..3633feb 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -1,87 +1,56 @@ -import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; +import { getSettingsManager } from "../utils/settings-manager"; import { MCPServerConfig } from "./client"; -const CONFIG_DIR = path.join(process.cwd(), ".grok"); -const SETTINGS_FILE = path.join(CONFIG_DIR, "settings.json"); - -export interface Settings { - model?: string; - mcpServers?: Record; -} - export interface MCPConfig { servers: MCPServerConfig[]; } -function loadSettings(): Settings { - try { - if (!fs.existsSync(SETTINGS_FILE)) { - return {}; - } - - const settingsData = fs.readFileSync(SETTINGS_FILE, "utf8"); - return JSON.parse(settingsData); - } catch (error) { - console.warn("Failed to load settings:", error); - return {}; - } -} - -function saveSettings(settings: Settings): void { - try { - // Ensure config directory exists - if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); - } - - fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); - } catch (error) { - console.error("Failed to save settings:", error); - throw error; - } -} - +/** + * Load MCP configuration from project settings + */ export function loadMCPConfig(): MCPConfig { - const settings = loadSettings(); - const servers = settings.mcpServers ? Object.values(settings.mcpServers) : []; + const manager = getSettingsManager(); + const projectSettings = manager.loadProjectSettings(); + const servers = projectSettings.mcpServers ? Object.values(projectSettings.mcpServers) : []; return { servers }; } export function saveMCPConfig(config: MCPConfig): void { - const settings = loadSettings(); - settings.mcpServers = {}; + const manager = getSettingsManager(); + const mcpServers: Record = {}; // Convert servers array to object keyed by name for (const server of config.servers) { - settings.mcpServers[server.name] = server; + mcpServers[server.name] = server; } - saveSettings(settings); + manager.updateProjectSetting('mcpServers', mcpServers); } export function addMCPServer(config: MCPServerConfig): void { - const settings = loadSettings(); - if (!settings.mcpServers) { - settings.mcpServers = {}; - } + const manager = getSettingsManager(); + const projectSettings = manager.loadProjectSettings(); + const mcpServers = projectSettings.mcpServers || {}; - settings.mcpServers[config.name] = config; - saveSettings(settings); + mcpServers[config.name] = config; + manager.updateProjectSetting('mcpServers', mcpServers); } export function removeMCPServer(serverName: string): void { - const settings = loadSettings(); - if (settings.mcpServers) { - delete settings.mcpServers[serverName]; - saveSettings(settings); + const manager = getSettingsManager(); + const projectSettings = manager.loadProjectSettings(); + const mcpServers = projectSettings.mcpServers; + + if (mcpServers) { + delete mcpServers[serverName]; + manager.updateProjectSetting('mcpServers', mcpServers); } } export function getMCPServer(serverName: string): MCPServerConfig | undefined { - const settings = loadSettings(); - return settings.mcpServers?.[serverName]; + const manager = getSettingsManager(); + const projectSettings = manager.loadProjectSettings(); + return projectSettings.mcpServers?.[serverName]; } // Predefined server configurations diff --git a/src/ui/components/api-key-input.tsx b/src/ui/components/api-key-input.tsx index b647366..19376cd 100644 --- a/src/ui/components/api-key-input.tsx +++ b/src/ui/components/api-key-input.tsx @@ -1,18 +1,12 @@ import React, { useState } from "react"; import { Box, Text, useInput, useApp } from "ink"; import { GrokAgent } from "../../agent/grok-agent"; -import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; +import { getSettingsManager } from "../../utils/settings-manager"; interface ApiKeyInputProps { onApiKeySet: (agent: GrokAgent) => void; } -interface UserSettings { - apiKey?: string; -} - export default function ApiKeyInput({ onApiKeySet }: ApiKeyInputProps) { const [input, setInput] = useState(""); const [error, setError] = useState(""); @@ -60,33 +54,10 @@ export default function ApiKeyInput({ onApiKeySet }: ApiKeyInputProps) { // Set environment variable for current process process.env.GROK_API_KEY = apiKey; - // Save to .grok/user-settings.json + // Save to user settings try { - const homeDir = os.homedir(); - const grokDir = path.join(homeDir, '.grok'); - const settingsFile = path.join(grokDir, 'user-settings.json'); - - // Create .grok directory if it doesn't exist - if (!fs.existsSync(grokDir)) { - fs.mkdirSync(grokDir, { mode: 0o700 }); - } - - // Load existing settings or create new - let settings: UserSettings = {}; - if (fs.existsSync(settingsFile)) { - try { - settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); - } catch { - settings = {}; - } - } - - // Update API key - settings.apiKey = apiKey; - - // Save settings - fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2), { mode: 0o600 }); - + const manager = getSettingsManager(); + manager.updateUserSetting('apiKey', apiKey); console.log(`\n✅ API key saved to ~/.grok/user-settings.json`); } catch (error) { console.log('\n⚠️ Could not save API key to settings file'); diff --git a/src/ui/components/model-selection.tsx b/src/ui/components/model-selection.tsx index f0604f4..8707f7f 100644 --- a/src/ui/components/model-selection.tsx +++ b/src/ui/components/model-selection.tsx @@ -3,7 +3,6 @@ import { Box, Text } from "ink"; interface ModelOption { model: string; - description: string; } interface ModelSelectionProps { @@ -34,9 +33,6 @@ export function ModelSelection({ > {modelOption.model} - - {modelOption.description} - ))} diff --git a/src/utils/model-config.ts b/src/utils/model-config.ts new file mode 100644 index 0000000..c08ab87 --- /dev/null +++ b/src/utils/model-config.ts @@ -0,0 +1,56 @@ +import { getSettingsManager, UserSettings, ProjectSettings } from './settings-manager'; + +export interface ModelOption { + model: string; +} + +export type ModelConfig = string; + +// Re-export interfaces for backward compatibility +export { UserSettings, ProjectSettings }; + +/** + * Get the effective current model + * Priority: project current model > user default model > system default + */ +export function getCurrentModel(): string { + const manager = getSettingsManager(); + return manager.getCurrentModel(); +} + +/** + * Load model configuration + * Priority: user-settings.json models > default hardcoded + */ +export function loadModelConfig(): ModelOption[] { + const manager = getSettingsManager(); + const models = manager.getAvailableModels(); + + return models.map(model => ({ + model: model.trim() + })); +} + +/** + * Get default models list + */ +export function getDefaultModels(): string[] { + const manager = getSettingsManager(); + return manager.getAvailableModels(); +} + +/** + * Update the current model in project settings + */ +export function updateCurrentModel(modelName: string): void { + const manager = getSettingsManager(); + manager.setCurrentModel(modelName); +} + +/** + * Update the user's default model preference + */ +export function updateDefaultModel(modelName: string): void { + const manager = getSettingsManager(); + manager.updateUserSetting('defaultModel', modelName); +} diff --git a/src/utils/settings-manager.ts b/src/utils/settings-manager.ts new file mode 100644 index 0000000..e7b68b8 --- /dev/null +++ b/src/utils/settings-manager.ts @@ -0,0 +1,294 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * User-level settings stored in ~/.grok/user-settings.json + * These are global settings that apply across all projects + */ +export interface UserSettings { + apiKey?: string; // Grok API key + baseURL?: string; // API base URL + defaultModel?: string; // User's preferred default model + models?: string[]; // Available models list +} + +/** + * Project-level settings stored in .grok/settings.json + * These are project-specific settings + */ +export interface ProjectSettings { + model?: string; // Current model for this project + mcpServers?: Record; // MCP server configurations +} + +/** + * Default values for user settings + */ +const DEFAULT_USER_SETTINGS: Partial = { + baseURL: "https://api.x.ai/v1", + defaultModel: "grok-4-latest", + models: [ + "grok-4-latest", + "grok-3-latest", + "grok-3-fast", + "grok-3-mini-fast" + ] +}; + +/** + * Default values for project settings + */ +const DEFAULT_PROJECT_SETTINGS: Partial = { + model: "grok-4-latest" +}; + +/** + * Unified settings manager that handles both user-level and project-level settings + */ +export class SettingsManager { + private static instance: SettingsManager; + + private userSettingsPath: string; + private projectSettingsPath: string; + + private constructor() { + // User settings path: ~/.grok/user-settings.json + this.userSettingsPath = path.join(os.homedir(), '.grok', 'user-settings.json'); + + // Project settings path: .grok/settings.json (in current working directory) + this.projectSettingsPath = path.join(process.cwd(), '.grok', 'settings.json'); + } + + /** + * Get singleton instance + */ + public static getInstance(): SettingsManager { + if (!SettingsManager.instance) { + SettingsManager.instance = new SettingsManager(); + } + return SettingsManager.instance; + } + + /** + * Ensure directory exists for a given file path + */ + private ensureDirectoryExists(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + } + + /** + * Load user settings from ~/.grok/user-settings.json + */ + public loadUserSettings(): UserSettings { + try { + if (!fs.existsSync(this.userSettingsPath)) { + // Create default user settings if file doesn't exist + this.saveUserSettings(DEFAULT_USER_SETTINGS); + return { ...DEFAULT_USER_SETTINGS }; + } + + const content = fs.readFileSync(this.userSettingsPath, 'utf-8'); + const settings = JSON.parse(content); + + // Merge with defaults to ensure all required fields exist + return { ...DEFAULT_USER_SETTINGS, ...settings }; + } catch (error) { + console.warn('Failed to load user settings:', error instanceof Error ? error.message : 'Unknown error'); + return { ...DEFAULT_USER_SETTINGS }; + } + } + + /** + * Save user settings to ~/.grok/user-settings.json + */ + public saveUserSettings(settings: Partial): void { + try { + this.ensureDirectoryExists(this.userSettingsPath); + + // Read existing settings directly to avoid recursion + let existingSettings: UserSettings = { ...DEFAULT_USER_SETTINGS }; + if (fs.existsSync(this.userSettingsPath)) { + try { + const content = fs.readFileSync(this.userSettingsPath, 'utf-8'); + const parsed = JSON.parse(content); + existingSettings = { ...DEFAULT_USER_SETTINGS, ...parsed }; + } catch (error) { + // If file is corrupted, use defaults + console.warn('Corrupted user settings file, using defaults'); + } + } + + const mergedSettings = { ...existingSettings, ...settings }; + + fs.writeFileSync( + this.userSettingsPath, + JSON.stringify(mergedSettings, null, 2), + { mode: 0o600 } // Secure permissions for API key + ); + } catch (error) { + console.error('Failed to save user settings:', error instanceof Error ? error.message : 'Unknown error'); + throw error; + } + } + + /** + * Update a specific user setting + */ + public updateUserSetting(key: K, value: UserSettings[K]): void { + const settings = { [key]: value } as Partial; + this.saveUserSettings(settings); + } + + /** + * Get a specific user setting + */ + public getUserSetting(key: K): UserSettings[K] { + const settings = this.loadUserSettings(); + return settings[key]; + } + + /** + * Load project settings from .grok/settings.json + */ + public loadProjectSettings(): ProjectSettings { + try { + if (!fs.existsSync(this.projectSettingsPath)) { + // Create default project settings if file doesn't exist + this.saveProjectSettings(DEFAULT_PROJECT_SETTINGS); + return { ...DEFAULT_PROJECT_SETTINGS }; + } + + const content = fs.readFileSync(this.projectSettingsPath, 'utf-8'); + const settings = JSON.parse(content); + + // Merge with defaults + return { ...DEFAULT_PROJECT_SETTINGS, ...settings }; + } catch (error) { + console.warn('Failed to load project settings:', error instanceof Error ? error.message : 'Unknown error'); + return { ...DEFAULT_PROJECT_SETTINGS }; + } + } + + /** + * Save project settings to .grok/settings.json + */ + public saveProjectSettings(settings: Partial): void { + try { + this.ensureDirectoryExists(this.projectSettingsPath); + + // Read existing settings directly to avoid recursion + let existingSettings: ProjectSettings = { ...DEFAULT_PROJECT_SETTINGS }; + if (fs.existsSync(this.projectSettingsPath)) { + try { + const content = fs.readFileSync(this.projectSettingsPath, 'utf-8'); + const parsed = JSON.parse(content); + existingSettings = { ...DEFAULT_PROJECT_SETTINGS, ...parsed }; + } catch (error) { + // If file is corrupted, use defaults + console.warn('Corrupted project settings file, using defaults'); + } + } + + const mergedSettings = { ...existingSettings, ...settings }; + + fs.writeFileSync( + this.projectSettingsPath, + JSON.stringify(mergedSettings, null, 2) + ); + } catch (error) { + console.error('Failed to save project settings:', error instanceof Error ? error.message : 'Unknown error'); + throw error; + } + } + + /** + * Update a specific project setting + */ + public updateProjectSetting(key: K, value: ProjectSettings[K]): void { + const settings = { [key]: value } as Partial; + this.saveProjectSettings(settings); + } + + /** + * Get a specific project setting + */ + public getProjectSetting(key: K): ProjectSettings[K] { + const settings = this.loadProjectSettings(); + return settings[key]; + } + + /** + * Get the current model with proper fallback logic: + * 1. Project-specific model setting + * 2. User's default model + * 3. System default + */ + public getCurrentModel(): string { + const projectModel = this.getProjectSetting('model'); + if (projectModel) { + return projectModel; + } + + const userDefaultModel = this.getUserSetting('defaultModel'); + if (userDefaultModel) { + return userDefaultModel; + } + + return DEFAULT_PROJECT_SETTINGS.model || 'grok-4-latest'; + } + + /** + * Set the current model for the project + */ + public setCurrentModel(model: string): void { + this.updateProjectSetting('model', model); + } + + /** + * Get available models list from user settings + */ + public getAvailableModels(): string[] { + const models = this.getUserSetting('models'); + return models || DEFAULT_USER_SETTINGS.models || []; + } + + /** + * Get API key from user settings or environment + */ + public getApiKey(): string | undefined { + // First check environment variable + const envApiKey = process.env.GROK_API_KEY; + if (envApiKey) { + return envApiKey; + } + + // Then check user settings + return this.getUserSetting('apiKey'); + } + + /** + * Get base URL from user settings or environment + */ + public getBaseURL(): string { + // First check environment variable + const envBaseURL = process.env.GROK_BASE_URL; + if (envBaseURL) { + return envBaseURL; + } + + // Then check user settings + const userBaseURL = this.getUserSetting('baseURL'); + return userBaseURL || DEFAULT_USER_SETTINGS.baseURL || 'https://api.x.ai/v1'; + } +} + +/** + * Convenience function to get the singleton instance + */ +export function getSettingsManager(): SettingsManager { + return SettingsManager.getInstance(); +} diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 283a990..88cff05 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -1,68 +1,4 @@ -import fs from "fs"; -import path from "path"; +// This file is kept for potential future convenience functions +// All settings management should use getSettingsManager() from './settings-manager' -interface Settings { - model?: string; - [key: string]: any; -} - -const SETTINGS_DIR = ".grok"; -const SETTINGS_FILE = "settings.json"; - -function getSettingsPath(): string { - return path.join(process.cwd(), SETTINGS_DIR, SETTINGS_FILE); -} - -function ensureSettingsDirectory(): void { - const settingsDir = path.join(process.cwd(), SETTINGS_DIR); - if (!fs.existsSync(settingsDir)) { - fs.mkdirSync(settingsDir, { recursive: true }); - } -} - -const DEFAULT_SETTINGS: Settings = { - model: "grok-4-latest", -}; - -export function loadSettings(): Settings { - try { - ensureSettingsDirectory(); - const settingsPath = getSettingsPath(); - - if (!fs.existsSync(settingsPath)) { - saveSettings(DEFAULT_SETTINGS); - return DEFAULT_SETTINGS; - } - - const settingsContent = fs.readFileSync(settingsPath, "utf-8"); - return JSON.parse(settingsContent); - } catch (error) { - console.warn("Failed to load settings:", error); - return {}; - } -} - -export function saveSettings(settings: Settings): void { - try { - ensureSettingsDirectory(); - const settingsPath = getSettingsPath(); - - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); - } catch (error) { - console.error("Failed to save settings:", error); - } -} - -export function updateSetting( - key: K, - value: Settings[K] -): void { - const currentSettings = loadSettings(); - currentSettings[key] = value; - saveSettings(currentSettings); -} - -export function getSetting(key: K): Settings[K] { - const settings = loadSettings(); - return settings[key]; -} +export { getSettingsManager, UserSettings, ProjectSettings } from './settings-manager';