diff --git a/.smallcode/plugins/anthropic-provider/adapter.js b/.smallcode/plugins/anthropic-provider/adapter.js new file mode 100644 index 00000000..e5529804 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/adapter.js @@ -0,0 +1,151 @@ +// Anthropic Claude adapter — implements IModelProvider for SmallCode plugins. +// Translates between SmallCode's ChatRequest/ChatResponse and the Anthropic Messages API. + +class AnthropicAdapter { + constructor(options = {}) { + this.name = 'anthropic'; + this.apiKeyEnv = options.apiKeyEnv || 'ANTHROPIC_API_KEY'; + this.baseUrl = options.baseUrl || 'https://api.anthropic.com/v1'; + this.defaultModel = options.defaultModel || 'claude-sonnet-4-20250514'; + this._apiKey = null; + } + + _getApiKey() { + if (!this._apiKey) { + this._apiKey = process.env[this.apiKeyEnv]; + if (!this._apiKey) { + throw new Error(`Missing API key: set ${this.apiKeyEnv} environment variable`); + } + } + return this._apiKey; + } + + _toAnthropicMessages(req) { + const systemMessages = []; + const userMessages = []; + + for (const msg of req.messages) { + if (msg.role === 'system') { + systemMessages.push(msg.content); + } else if (msg.role === 'user') { + userMessages.push({ role: 'user', content: msg.content }); + } else if (msg.role === 'assistant') { + const assistantMsg = { role: 'assistant', content: msg.content }; + if (msg.tool_calls && msg.tool_calls.length > 0) { + assistantMsg.content = [ + { type: 'text', text: msg.content || '' }, + ...msg.tool_calls.map(tc => ({ + type: 'tool_use', + id: tc.id, + name: tc.name, + input: JSON.parse(tc.arguments || '{}'), + })), + ]; + } + userMessages.push(assistantMsg); + } else if (msg.role === 'tool') { + userMessages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: msg.tool_call_id, + content: msg.content, + }], + }); + } + } + + return { + system: systemMessages.join('\n') || undefined, + messages: userMessages, + }; + } + + _toAnthropicTools(tools) { + if (!tools || tools.length === 0) return undefined; + return tools.map(t => ({ + name: t.name, + description: t.description, + input_schema: t.parameters, + })); + } + + async chat(req, signal) { + const apiKey = this._getApiKey(); + const { system, messages } = this._toAnthropicMessages(req); + + const body = { + model: req.model || this.defaultModel, + messages, + max_tokens: req.max_output || 4096, + }; + if (system) body.system = system; + if (req.temperature !== undefined) body.temperature = req.temperature; + if (req.top_p !== undefined) body.top_p = req.top_p; + if (req.stop) body.stop_sequences = req.stop; + + const anthropicTools = this._toAnthropicTools(req.tools); + if (anthropicTools) { + body.tools = anthropicTools; + if (req.tool_choice) { + body.tool_choice = req.tool_choice === 'auto' + ? { type: 'auto' } + : req.tool_choice === 'required' + ? { type: 'any' } + : { type: 'auto' }; + } + } + + const response = await fetch(`${this.baseUrl}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + signal, + }); + + if (!response.ok) { + const errText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Anthropic API error ${response.status}: ${errText.slice(0, 200)}`); + } + + const data = await response.json(); + + // Parse response content blocks + let content = ''; + const toolCalls = []; + + for (const block of data.content || []) { + if (block.type === 'text') { + content += block.text; + } else if (block.type === 'tool_use') { + toolCalls.push({ + id: block.id, + name: block.name, + arguments: JSON.stringify(block.input), + }); + } + } + + return { + content, + usage: { + prompt_tokens: data.usage?.input_tokens || 0, + completion_tokens: data.usage?.output_tokens || 0, + }, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + raw: data, + }; + } + + countTokens(text) { + if (!text) return 0; + // Claude approximate: ~4 chars per token (close to BPE average) + return Math.ceil(text.length / 4); + } +} + +module.exports = AnthropicAdapter; diff --git a/.smallcode/plugins/anthropic-provider/cleanup.js b/.smallcode/plugins/anthropic-provider/cleanup.js new file mode 100644 index 00000000..4ea29bb8 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/cleanup.js @@ -0,0 +1,4 @@ +// Plugin shutdown: cleanup any resources. +module.exports = async function cleanup() { + // Nothing to clean up — API key is not cached in memory +}; diff --git a/.smallcode/plugins/anthropic-provider/init.js b/.smallcode/plugins/anthropic-provider/init.js new file mode 100644 index 00000000..5772b630 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/init.js @@ -0,0 +1,8 @@ +// Plugin init: verify API key is available at startup. +module.exports = async function init({ config }) { + const keyEnv = 'ANTHROPIC_API_KEY'; + if (!process.env[keyEnv]) { + console.log(` \x1b[33m⚠ Anthropic plugin loaded but ${keyEnv} is not set.\x1b[0m`); + console.log(` Set it in your .env or environment to use the anthropic provider.`); + } +}; diff --git a/.smallcode/plugins/anthropic-provider/on-error.js b/.smallcode/plugins/anthropic-provider/on-error.js new file mode 100644 index 00000000..92041ea9 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/on-error.js @@ -0,0 +1,8 @@ +// Hook: on_error — runs when API call fails +module.exports = async function onError({ provider, model, error }) { + if (error?.status === 401) { + console.log(' \x1b[31mAuth failed — check ANTHROPIC_API_KEY\x1b[0m'); + } else if (error?.status === 429) { + console.log(' \x1b[33mRate limited by Anthropic — backing off\x1b[0m'); + } +}; diff --git a/.smallcode/plugins/anthropic-provider/plugin.json b/.smallcode/plugins/anthropic-provider/plugin.json new file mode 100644 index 00000000..f9b08407 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "anthropic-provider", + "version": "0.1.0", + "description": "Anthropic Claude provider for SmallCode", + "init": "./init.js", + "shutdown": "./cleanup.js", + "permissions": { "read": true, "network": true }, + "providers": [ + { + "name": "anthropic", + "module": "./adapter.js", + "options": { "apiKeyEnv": "ANTHROPIC_API_KEY", "baseUrl": "https://api.anthropic.com/v1" }, + "capabilities": { "tools": true, "streaming": true, "vision": true, "tokenCounting": "exact" } + } + ], + "hooks": [ + { "event": "pre_request", "filter": ["anthropic"], "handler": "./pre-request.js" }, + { "event": "post_request", "filter": ["anthropic"], "handler": "./post-request.js" }, + { "event": "on_error", "handler": "./on-error.js" } + ] +} diff --git a/.smallcode/plugins/anthropic-provider/post-request.js b/.smallcode/plugins/anthropic-provider/post-request.js new file mode 100644 index 00000000..3450c707 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/post-request.js @@ -0,0 +1,4 @@ +// Hook: post_request — runs after successful Anthropic API response +module.exports = async function postRequest({ provider, model, response, usage }) { + // Could log usage, track metrics, etc. +}; diff --git a/.smallcode/plugins/anthropic-provider/pre-request.js b/.smallcode/plugins/anthropic-provider/pre-request.js new file mode 100644 index 00000000..79faddfb --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/pre-request.js @@ -0,0 +1,4 @@ +// Hook: pre_request — runs before Anthropic API call +module.exports = async function preRequest({ provider, model, messages }) { + // Could inject metadata, log, modify request, etc. +}; diff --git a/.smallcode/plugins/prompt-inject/plugin.json b/.smallcode/plugins/prompt-inject/plugin.json new file mode 100644 index 00000000..8e7d92b6 --- /dev/null +++ b/.smallcode/plugins/prompt-inject/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "prompt-inject", + "version": "1.0.0", + "description": "Provider plugin that wraps any registered provider and injects content into system prompts. Use for custom instructions, RAG context, or persona overrides.", + "providers": [ + { + "name": "prompt-inject", + "module": "../../src/compiled/providers/prompt_inject.js", + "description": "Wraps any registered provider and injects content into system prompts" + } + ] +} diff --git a/bin/commands.js b/bin/commands.js index 0db4541e..140c4c29 100644 --- a/bin/commands.js +++ b/bin/commands.js @@ -495,47 +495,71 @@ module.exports = function createCommandHandler(config, conversationHistory, impr } else if (sub === 'install') { const pkg = parts[2]; if (!pkg) { - console.log(chalk.gray(' Usage: /plugin install ')); - console.log(chalk.gray(' Example: /plugin install smallcode-plugin-lint')); - console.log(chalk.gray(' Example: /plugin install github:user/repo')); + console.log(chalk.gray(' Usage: /plugin install [--scope project|user|global]')); + console.log(chalk.gray(' Examples:')); + console.log(chalk.gray(' /plugin install smallcode-plugin-lint')); + console.log(chalk.gray(' /plugin install github:user/repo --scope global')); + console.log(chalk.gray(' /plugin install @scope/pkg --scope user')); } else { - const { execFileSync } = require('child_process'); - const pluginsDir = require('path').join(process.cwd(), '.smallcode', 'plugins'); - const fs = require('fs'); - if (!fs.existsSync(pluginsDir)) fs.mkdirSync(pluginsDir, { recursive: true }); - // Validate package name — only allow npm-safe characters to prevent injection. - // Legitimate names: @scope/pkg, pkg-name, github:user/repo - if (!/^[@a-zA-Z0-9._\-/: ]+$/.test(pkg)) { - console.log(chalk.red(` ✗ Invalid package name: ${pkg}`)); + // Parse --scope flag + let scope = 'project'; + const scopeIdx = parts.indexOf('--scope'); + if (scopeIdx !== -1 && parts[scopeIdx + 1]) { + scope = parts[scopeIdx + 1]; + } + const validScopes = { project: '.smallcode', user: '.smallcode', global: '.config/smallcode' }; + if (!validScopes[scope]) { + console.log(chalk.red(` ✗ Unknown scope "${scope}". Use: project, user, or global.`)); } else { - console.log(chalk.gray(` Installing ${pkg}...`)); - try { - execFileSync('npm', ['install', '--prefix', pluginsDir, pkg], { encoding: 'utf-8', timeout: 60000, cwd: process.cwd() }); - console.log(chalk.green(` ✓ Installed ${pkg}`)); - console.log(chalk.gray(' Restart SmallCode to activate.')); - } catch (e) { - console.log(chalk.red(` ✗ Install failed: ${((e.stderr || '') + (e.message || '')).slice(0, 200)}`)); + const os = require('os'); + const { execFileSync } = require('child_process'); + const pluginsDir = scope === 'project' + ? require('path').join(process.cwd(), '.smallcode', 'plugins') + : require('path').join(os.homedir(), validScopes[scope], 'plugins'); + const fs = require('fs'); + if (!fs.existsSync(pluginsDir)) fs.mkdirSync(pluginsDir, { recursive: true }); + if (!/^[@a-zA-Z0-9._\-/: ]+$/.test(pkg)) { + console.log(chalk.red(` ✗ Invalid package name: ${pkg}`)); + } else { + console.log(chalk.gray(` Installing ${pkg} (${scope} → ${pluginsDir})...`)); + try { + execFileSync('npm', ['install', '--prefix', pluginsDir, pkg], { encoding: 'utf-8', timeout: 60000, cwd: process.cwd() }); + console.log(chalk.green(` ✓ Installed ${pkg}`)); + console.log(chalk.gray(' Restart SmallCode to activate.')); + } catch (e) { + console.log(chalk.red(` ✗ Install failed: ${((e.stderr || '') + (e.message || '')).slice(0, 200)}`)); + } } } } } else if (sub === 'remove') { const pkg = parts[2]; if (!pkg) { - console.log(chalk.gray(' Usage: /plugin remove ')); + console.log(chalk.gray(' Usage: /plugin remove [--scope project|user|global]')); } else { - const pluginDir = require('path').join(process.cwd(), '.smallcode', 'plugins', pkg); + let scope = 'project'; + const scopeIdx = parts.indexOf('--scope'); + if (scopeIdx !== -1 && parts[scopeIdx + 1]) { + scope = parts[scopeIdx + 1]; + } + const os = require('os'); + const scopeMap = { project: '.smallcode', user: '.smallcode', global: '.config/smallcode' }; + const pluginDir = scope === 'project' + ? require('path').join(process.cwd(), '.smallcode', 'plugins', pkg) + : require('path').join(os.homedir(), scopeMap[scope], 'plugins', pkg); const fs = require('fs'); if (fs.existsSync(pluginDir)) { fs.rmSync(pluginDir, { recursive: true }); - console.log(chalk.green(` ✓ Removed ${pkg}`)); + console.log(chalk.green(` ✓ Removed ${pkg} (${scope})`)); } else { - console.log(chalk.red(` Plugin "${pkg}" not found in .smallcode/plugins/`)); + console.log(chalk.red(` Plugin "${pkg}" not found in ${scope} plugins dir`)); } } } else { - console.log(chalk.gray(' /plugin list Show installed plugins')); - console.log(chalk.gray(' /plugin install Install from npm/github')); - console.log(chalk.gray(' /plugin remove Remove a plugin')); + console.log(chalk.gray(' /plugin list Show installed plugins')); + console.log(chalk.gray(' /plugin install [--scope ...] Install (default: project)')); + console.log(chalk.gray(' /plugin remove [--scope ...] Remove a plugin')); + console.log(chalk.gray(' Scopes: project (./.smallcode), user (~/.smallcode), global (~/.config/smallcode)')); } console.log(''); rl.prompt(); @@ -744,7 +768,8 @@ module.exports = function createCommandHandler(config, conversationHistory, impr console.log(` ${chalk.cyan('/mcp')} ${chalk.gray('Show connected MCP servers')}`); console.log(` ${chalk.cyan('/skill')} ${chalk.gray('Manage reusable skills')}`); console.log(` ${chalk.cyan('/plugin')} ${chalk.gray('List installed plugins')}`); - console.log(` ${chalk.cyan('/sessions')} ${chalk.gray('List/resume saved sessions')}`); + console.log(` ${chalk.cyan('/provider')} ${chalk.gray('Configure LLM provider (interactive wizard)')}`); + console.log(` ${chalk.cron('/sessions')} ${chalk.gray('List/resume saved sessions')}`); console.log(` ${chalk.cyan('/trace')} ${chalk.gray('View/export execution traces')}`); console.log(` ${chalk.cyan('/eval')} ${chalk.gray('Run prompt evaluation')}`); console.log(` ${chalk.cyan('/clear')} ${chalk.gray('Reset entire session')}`); @@ -753,12 +778,32 @@ module.exports = function createCommandHandler(config, conversationHistory, impr rl.prompt(); return; + case '/provider': { + const sub = (parts[1] || '').trim(); + if (sub === 'status' || sub === '--status' || sub === '-s') { + const pProviderStatus = require('./provider-wizard/tool-status'); + console.log(pProviderStatus()); + } else { + const pWizard = require('./provider-wizard/wizard'); + const { configureProvider } = require('../src/compiled/providers/registry'); + const result = await pWizard.runWizard({ interactive: true }); + if (result.success) { + try { configureProvider(); } catch {} + console.log(result.providerId || ''); + } + } + console.log(''); + rl.prompt(); + return; + } + default: { - // Try plugin commands before showing "unknown" + // Try plugin commands — strip leading / for lookup const { PluginLoader } = require('../src/plugins/loader'); const pl = new PluginLoader(process.cwd()).loadAll(); - if (pl.commands[parts[0]]) { - const result = await pl.executeCommand(parts[0], parts.slice(1).join(' '), { config, conversationHistory }); + const cmdName = parts[0].replace(/^\//, ''); + if (pl.commands[cmdName]) { + const result = await pl.executeCommand(cmdName, parts.slice(1).join(' '), { config, conversationHistory }); if (result) console.log(result); } else { console.log(chalk.gray(` Unknown: ${parts[0]}. Type /help`)); diff --git a/bin/config.js b/bin/config.js index 9a0890bf..b4102eb9 100644 --- a/bin/config.js +++ b/bin/config.js @@ -93,11 +93,18 @@ function loadConfig(flags = {}) { async function checkEndpoint(config) { const baseUrl = config.model.baseUrl || process.env.OLLAMA_HOST || 'http://localhost:11434'; + // Plugin-registered providers handle their own connectivity + const { providerRegistry } = require('../src/compiled/providers/registry'); + if (providerRegistry.has(config.model.provider)) { + console.log(` Using plugin provider: ${config.model.provider}`); + return true; + } + // OpenAI-compatible endpoint (LM Studio, vLLM, OpenRouter, etc.) if (config.model.provider === 'openai' || baseUrl.includes('/v1')) { try { const headers = {}; - const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey; + const apiKey = process.env.SMALLCODE_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey; if (apiKey) { headers['Authorization'] = `Bearer ${apiKey}`; } @@ -154,7 +161,7 @@ async function checkEndpoint(config) { */ function buildAuthHeaders(config) { const headers = { 'Content-Type': 'application/json' }; - const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey; + const apiKey = process.env.SMALLCODE_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey; if (apiKey) { headers['Authorization'] = `Bearer ${apiKey}`; } diff --git a/bin/escalation.js b/bin/escalation.js index a148d10f..ea2e2efa 100644 --- a/bin/escalation.js +++ b/bin/escalation.js @@ -110,6 +110,23 @@ Be precise. Don't explain unnecessarily. Just fix it. ${systemPromptExtra}`, }; + // Plugin-registered providers: use the registry + const { providerRegistry } = require('../src/compiled/providers/registry'); + const pluginProvider = providerRegistry.get(this.provider); + if (pluginProvider) { + try { + return await pluginProvider.chat({ + model: this.model, + messages: [systemMsg, ...messages], + temperature: 0.1, + maxOutput: 4096, + tools: tools || [], + }); + } catch (err) { + return { error: `Plugin provider "${this.provider}" failed: ${err.message}` }; + } + } + if (this.provider === 'anthropic') { return this._callAnthropic([systemMsg, ...messages], tools); } else { diff --git a/bin/executor.js b/bin/executor.js index 4383d12c..552458ca 100644 --- a/bin/executor.js +++ b/bin/executor.js @@ -746,6 +746,36 @@ async function executeTool(name, args, ctx) { return { result: `Category: ${category}. Proceed with your tool call.`, category }; } + case 'configure_provider': { + const { runWizard } = require('./provider-wizard/wizard'); + const { configureProvider: activateProvider } = require('../src/compiled/providers/registry'); + const hasAnyParam = args.provider || args.baseUrl || args.model || args.apiKey; + let result; + if (!hasAnyParam) { + result = await runWizard({ interactive: true }); + } else { + result = await runWizard({ + interactive: false, + provider: args.provider, + baseUrl: args.baseUrl, + model: args.model, + apiKey: args.apiKey, + escalationProvider: args.escalationProvider, + escalationModel: args.escalationModel, + }); + } + if (result.success) { + try { activateProvider(); } catch {} + return { result: `Provider configured: ${result.provider} (${result.baseUrl}) model=${result.model}${result.escalation ? ` escalation=${result.escalation}` : ''}. Restart SmallCode to apply.` }; + } + return { error: result.error }; + } + + case 'provider_status': { + const { getStatus, formatStatus } = require('./provider-wizard/status'); + return { result: formatStatus(getStatus()) }; + } + default: { if (mcpClient && mcpClient.isMCPTool(name)) { const mcpResult = await mcpClient.callTool(name, args); diff --git a/bin/init.js b/bin/init.js old mode 100644 new mode 100755 diff --git a/bin/provider-wizard/status.js b/bin/provider-wizard/status.js new file mode 100644 index 00000000..9c60f3c9 --- /dev/null +++ b/bin/provider-wizard/status.js @@ -0,0 +1,112 @@ +// Provider Wizard — config status detection +// Reads .env file, env vars, and smallcode.toml to report current state. + +const fs = require('fs'); +const path = require('path'); + +const PROVIDERS = { + lmstudio: { name: 'LM Studio', defaultUrl: 'http://localhost:1234/v1', keyEnv: null }, + ollama: { name: 'Ollama', defaultUrl: 'http://localhost:11434/v1', keyEnv: null }, + openrouter: { name: 'OpenRouter', defaultUrl: 'https://openrouter.ai/api/v1', keyEnv: 'OPENAI_API_KEY' }, + openai: { name: 'OpenAI', defaultUrl: 'https://api.openai.com/v1', keyEnv: 'OPENAI_API_KEY' }, + anthropic: { name: 'Anthropic', defaultUrl: 'https://api.anthropic.com/v1', keyEnv: 'ANTHROPIC_API_KEY' }, + deepseek: { name: 'DeepSeek', defaultUrl: 'https://api.deepseek.com/v1', keyEnv: 'DEEPSEEK_API_KEY' }, + custom: { name: 'Custom endpoint', defaultUrl: '', keyEnv: 'SMALLCODE_API_KEY' }, +}; + +function parseEnvFile(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const vars = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + vars[key] = val; + } + return vars; + } catch { + return {}; + } +} + +function getStatus() { + const cwd = process.cwd(); + const envFile = path.join(cwd, '.env'); + const tomlFile = path.join(cwd, 'smallcode.toml'); + + const envFileVars = parseEnvFile(envFile); + + const provider = process.env.SMALLCODE_PROVIDER || envFileVars.SMALLCODE_PROVIDER || 'openai'; + const baseUrl = process.env.SMALLCODE_BASE_URL || envFileVars.SMALLCODE_BASE_URL || ''; + const model = process.env.SMALLCODE_MODEL || envFileVars.SMALLCODE_MODEL || ''; + + // API keys: env var > .env file + const apiKeys = {}; + for (const [id, info] of Object.entries(PROVIDERS)) { + if (!info.keyEnv) continue; + apiKeys[id] = process.env[info.keyEnv] || envFileVars[info.keyEnv] || null; + } + + // Escalation from smallcode.toml + let escalation = null; + try { + const content = fs.readFileSync(tomlFile, 'utf-8'); + const lines = content.split('\n'); + let inEscalation = false; + for (const line of lines) { + if (line.trim() === '[escalation]') { inEscalation = true; continue; } + if (line.trim().startsWith('[') && inEscalation) break; + if (inEscalation) { + const pMatch = line.match(/^provider\s*=\s*"?([^"#]+)"?/); + if (pMatch) escalation = { provider: pMatch[1].trim() }; + const mMatch = line.match(/^model\s*=\s*"?([^"#]+)"?/); + if (mMatch && escalation) escalation.model = mMatch[1].trim(); + } + } + } catch {} + + // Check if key matches provider + const keyForProvider = PROVIDERS[provider]?.keyEnv ? apiKeys[provider] : true; // local providers don't need keys + const hasValidKey = keyForProvider === true || !!keyForProvider; + + return { + provider, + baseUrl: baseUrl || PROVIDERS[provider]?.defaultUrl || '', + model, + hasValidKey, + apiKeys, + escalation, + envFileExists: fs.existsSync(envFile), + providers: PROVIDERS, + }; +} + +function formatStatus(status) { + const lines = []; + lines.push(` Provider: ${status.provider}`); + lines.push(` Base URL: ${status.baseUrl}`); + lines.push(` Model: ${status.model || '(not set)'}`); + lines.push(` API Key: ${status.hasValidKey ? 'set' : 'missing'}`); + lines.push(` Escalation: ${status.escalation ? `${status.escalation.provider} / ${status.escalation.model || 'default'}` : 'none'}`); + lines.push(` Config file: ${status.envFileExists ? '.env exists' : 'no .env file'}`); + + // Show which keys are present + const keyStatuses = []; + for (const [id, val] of Object.entries(status.apiKeys)) { + if (val) keyStatuses.push(`${id}=***`); + } + if (keyStatuses.length) { + lines.push(` Keys found: ${keyStatuses.join(', ')}`); + } + + return lines.join('\n'); +} + +module.exports = { PROVIDERS, getStatus, formatStatus, parseEnvFile }; diff --git a/bin/provider-wizard/tool-configure.js b/bin/provider-wizard/tool-configure.js new file mode 100644 index 00000000..9f270a0d --- /dev/null +++ b/bin/provider-wizard/tool-configure.js @@ -0,0 +1,29 @@ +// Provider Wizard — configure_provider tool handler + +const { runWizard } = require('./wizard'); + +module.exports = async function configureProvider(params) { + const hasAnyParam = params.provider || params.baseUrl || params.model || params.apiKey; + if (!hasAnyParam) { + const result = await runWizard({ interactive: true }); + if (result.success) { + return `Provider configured: ${result.provider} (${result.baseUrl}) model=${result.model}${result.escalation ? ` escalation=${result.escalation}` : ''}. Restart SmallCode to apply.`; + } + return `Configuration failed: ${result.error}`; + } + + const result = await runWizard({ + interactive: false, + provider: params.provider, + baseUrl: params.baseUrl, + model: params.model, + apiKey: params.apiKey, + escalationProvider: params.escalationProvider, + escalationModel: params.escalationModel, + }); + + if (result.success) { + return `Provider configured: ${result.provider} (${result.baseUrl}) model=${result.model}${result.escalation ? ` escalation=${result.escalation}` : ''}. Restart SmallCode to apply.`; + } + return `Configuration failed: ${result.error}`; +}; diff --git a/bin/provider-wizard/tool-status.js b/bin/provider-wizard/tool-status.js new file mode 100644 index 00000000..daa7217f --- /dev/null +++ b/bin/provider-wizard/tool-status.js @@ -0,0 +1,7 @@ +// Provider Wizard — provider_status tool handler + +const { getStatus, formatStatus } = require('./status'); + +module.exports = function providerStatus() { + return formatStatus(getStatus()); +}; diff --git a/bin/provider-wizard/wizard.js b/bin/provider-wizard/wizard.js new file mode 100644 index 00000000..723f449e --- /dev/null +++ b/bin/provider-wizard/wizard.js @@ -0,0 +1,318 @@ +// Provider Wizard — interactive readline wizard +// Shared logic for /provider command and configure_provider tool + +const readline = require('readline'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { PROVIDERS, parseEnvFile } = require('./status'); + +async function ask(rl, question, defaultVal) { + return new Promise((resolve) => { + const prompt = defaultVal ? `${question} [${defaultVal}]: ` : `${question}: `; + rl.question(prompt, (answer) => { + resolve(answer.trim() || defaultVal || ''); + }); + }); +} + +async function askNumber(rl, question, options) { + return new Promise((resolve) => { + const prompt = `${question}\n${options.map((o, i) => ` ${i + 1}. ${o}`).join('\n')}\n> `; + rl.question(prompt, (answer) => { + const n = parseInt(answer.trim()) - 1; + resolve(n >= 0 && n < options.length ? n : -1); + }); + }); +} + +async function askYesNo(rl, question, defaultYes = true) { + const suffix = defaultYes ? '[Y/n]' : '[y/N]'; + return new Promise((resolve) => { + rl.question(`${question} ${suffix} `, (answer) => { + const val = answer.trim().toLowerCase(); + if (!val) return resolve(defaultYes); + resolve(val === 'y' || val === 'yes'); + }); + }); +} + +function maskKey(key) { + if (!key) return ''; + if (key.length <= 8) return '***'; + return key.slice(0, 4) + '***' + key.slice(-4); +} + +async function validateApiKey(provider, apiKey, baseUrl) { + if (!apiKey) return { valid: false, error: 'No API key provided' }; + + const info = PROVIDERS[provider]; + if (!info || !info.keyEnv) return { valid: true, error: null }; // local providers, skip check + + const url = (baseUrl || info.defaultUrl || '').replace(/\/+$/, ''); + if (!url) return { valid: true, error: null }; // no URL to validate against + + try { + const res = await fetch(`${url}/models`, { + headers: { 'Authorization': `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10000), + }); + if (res.ok) return { valid: true, error: null }; + if (res.status === 401) return { valid: false, error: 'Invalid API key (got 401)' }; + if (res.status === 403) return { valid: false, error: 'API key rejected (got 403)' }; + // Some providers return 400 for /models but key is valid + return { valid: true, error: null }; + } catch (e) { + if (e.name === 'AbortError' || e.name === 'TimeoutError') { + return { valid: false, error: 'Request timed out — check your network' }; + } + return { valid: false, error: `Connection failed: ${e.message}` }; + } +} + +function mergeEnvFile(filePath, newVars) { + let lines = []; + try { + lines = fs.readFileSync(filePath, 'utf-8').split('\n'); + } catch {} + + const keys = new Set(Object.keys(newVars)); + const result = []; + const written = new Set(); + + for (const line of lines) { + const trimmed = line.trim(); + const eq = trimmed.indexOf('='); + if (eq !== -1) { + const key = trimmed.slice(0, eq).trim(); + if (keys.has(key)) { + result.push(`${key}=${newVars[key]}`); + written.add(key); + continue; + } + } + result.push(line); + } + + // Append new keys that weren't in the file + const newEntries = Object.entries(newVars).filter(([k]) => !written.has(k)); + if (newEntries.length) { + if (result.length && result[result.length - 1].trim() !== '') result.push(''); + result.push('# Provider configuration (added by /provider wizard)'); + for (const [k, v] of newEntries) { + result.push(`${k}=${v}`); + } + } + + return result.join('\n'); +} + +async function runWizard(options = {}) { + const isInteractive = options.interactive !== false; + const cwd = process.cwd(); + const envPath = path.join(cwd, '.env'); + const tomlPath = path.join(cwd, 'smallcode.toml'); + const globalConfigDir = path.join(os.homedir(), '.config', 'smallcode'); + const globalEnvPath = path.join(globalConfigDir, '.env'); + + // Load existing env + const existingEnv = parseEnvFile(envPath); + + let rl = null; + if (isInteractive) { + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + } + + const choices = Object.entries(PROVIDERS); + const providerNames = choices.map(([, p]) => p.name); + + try { + if (isInteractive) { + console.log(''); + console.log(' \x1b[1;36mProvider Wizard\x1b[0m'); + console.log(' Configure your LLM provider for SmallCode\n'); + } + + // Step 1: Select provider + const providerKeys = choices.map(([k]) => k); + let provider = options.provider || ''; + + if (!provider && isInteractive) { + const idx = await askNumber(rl, ' Select a provider:', providerNames); + provider = idx >= 0 ? providerKeys[idx] : 'openai'; + } + provider = provider || 'openai'; + const providerInfo = PROVIDERS[provider]; + + if (!providerInfo) { + return { success: false, error: `Unknown provider: ${provider}` }; + } + + if (isInteractive) { + console.log(` \x1b[32mSelected: ${providerInfo.name}\x1b[0m\n`); + } + + // Step 2: Base URL + let baseUrl = options.baseUrl || ''; + if (!baseUrl && isInteractive) { + baseUrl = await ask(rl, ` Base URL for ${providerInfo.name}`, providerInfo.defaultUrl); + } + baseUrl = baseUrl || providerInfo.defaultUrl; + + // Step 3: API key (cloud providers only) + let apiKey = options.apiKey || ''; + if (providerInfo.keyEnv && !apiKey) { + const envKey = process.env[providerInfo.keyEnv] || existingEnv[providerInfo.keyEnv] || ''; + if (isInteractive) { + if (envKey) { + console.log(` API key found in ${providerInfo.keyEnv}: ${maskKey(envKey)}`); + const change = await askYesNo(rl, ' Change it?', false); + if (change) { + apiKey = await ask(rl, ` API key for ${providerInfo.name}`, ''); + } else { + apiKey = envKey; + } + } else { + apiKey = await ask(rl, ` API key for ${providerInfo.name} (${providerInfo.keyEnv})`, ''); + } + } else { + apiKey = envKey; + } + } else if (!providerInfo.keyEnv && isInteractive) { + console.log(` No API key needed for ${providerInfo.name} (local provider)\n`); + } + + // Step 4: Validate API key + if (providerInfo.keyEnv && apiKey && isInteractive) { + process.stdout.write(' Validating API key...'); + const result = await validateApiKey(provider, apiKey, baseUrl); + if (result.valid) { + console.log(` \x1b[32mvalid\x1b[0m`); + } else { + console.log(` \x1b[31m${result.error}\x1b[0m`); + const retry = await askYesNo(rl, ' Continue anyway?', false); + if (!retry) { + return { success: false, error: result.error }; + } + } + } + + // Step 5: Model name + const defaultModels = { + lmstudio: '', + ollama: '', + openrouter: 'openai/gpt-4o-mini', + openai: 'gpt-4o-mini', + anthropic: 'claude-sonnet-4-5', + deepseek: 'deepseek-coder', + custom: '', + }; + let model = options.model || ''; + if (!model && isInteractive) { + model = await ask(rl, ' Model name', defaultModels[provider] || ''); + } + model = model || defaultModels[provider] || ''; + + // Step 6: Escalation (optional) + let escalationProvider = options.escalationProvider || ''; + let escalationModel = options.escalationModel || ''; + if (isInteractive) { + const setupEsc = await askYesNo(rl, ' Configure a fallback/escalation provider?', false); + if (setupEsc) { + const escChoices = providerNames; + const escIdx = await askNumber(rl, ' Select escalation provider:', escChoices); + if (escIdx >= 0 && escIdx < providerKeys.length) { + escalationProvider = providerKeys[escIdx]; + const escDefault = defaultModels[escalationProvider] || ''; + escalationModel = await ask(rl, ' Escalation model', escDefault); + } + } + } + + // Step 7: Write provider config to global env (~/.config/smallcode/smallcode.env) + // so it persists across projects. Project .env can still override. + const envVars = { + SMALLCODE_PROVIDER: provider, + SMALLCODE_BASE_URL: baseUrl, + SMALLCODE_MODEL: model, + }; + if (providerInfo.keyEnv && apiKey) { + envVars[providerInfo.keyEnv] = apiKey; + } + + // Always write to global config + try { + fs.mkdirSync(globalConfigDir, { recursive: true }); + const globalMerged = mergeEnvFile(globalEnvPath, envVars); + fs.writeFileSync(globalEnvPath, globalMerged, 'utf-8'); + } catch {} + + // Also write to project .env if it exists or if we're in the project root + if (fs.existsSync(envPath) || fs.existsSync(tomlPath)) { + const merged = mergeEnvFile(envPath, envVars); + fs.writeFileSync(envPath, merged, 'utf-8'); + } + + // Step 8: Write escalation to smallcode.toml + if (escalationProvider) { + let tomlContent = ''; + try { tomlContent = fs.readFileSync(tomlPath, 'utf-8'); } catch {} + + const escBlock = [ + '', + '[escalation]', + `provider = "${escalationProvider}"`, + escalationModel ? `model = "${escalationModel}"` : '', + ].filter(Boolean).join('\n') + '\n'; + + if (tomlContent.includes('[escalation]')) { + // Replace existing escalation section + const start = tomlContent.indexOf('[escalation]'); + let end = tomlContent.length; + const nextSection = tomlContent.indexOf('\n[', start + 1); + if (nextSection !== -1) end = nextSection; + tomlContent = tomlContent.slice(0, start).trimEnd() + '\n\n' + escBlock + tomlContent.slice(end).trimStart(); + } else { + tomlContent = tomlContent.trimEnd() + '\n' + escBlock; + } + fs.writeFileSync(tomlPath, tomlContent.trimEnd() + '\n', 'utf-8'); + } + + // Summary + const result = { + success: true, + provider: providerInfo.name, + baseUrl, + model, + key: providerInfo.keyEnv ? maskKey(apiKey) : 'n/a', + escalation: escalationProvider + ? `${PROVIDERS[escalationProvider]?.name || escalationProvider}${escalationModel ? ' / ' + escalationModel : ''}` + : null, + }; + + if (isInteractive) { + console.log(''); + console.log(' \x1b[1;32mConfiguration written!\x1b[0m'); + console.log(` \x1b[2m Provider: ${result.provider}\x1b[0m`); + console.log(` \x1b[2m Base URL: ${result.baseUrl}\x1b[0m`); + console.log(` \x1b[2m Model: ${result.model}\x1b[0m`); + console.log(` \x1b[2m API Key: ${result.key}\x1b[0m`); + if (result.escalation) { + console.log(` \x1b[2m Escalation: ${result.escalation}\x1b[0m`); + } + console.log(''); + console.log(' \x1b[33mRestart SmallCode to apply changes.\x1b[0m'); + console.log(''); + } + + return result; + + } finally { + if (rl) rl.close(); + } +} + +module.exports = { runWizard, ask, askNumber, askYesNo, validateApiKey, mergeEnvFile }; diff --git a/bin/smallcode.js b/bin/smallcode.js index b11afba2..436fbade 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -37,7 +37,8 @@ const fs = require('fs'); // Don't override existing env vars if (!process.env[key]) process.env[key] = value; } - break; // Use first found .env file + // Don't break — load all env files so global config is always available. + // Project .env values take priority since they're loaded first (line 38 won't overwrite). } catch {} } })(); @@ -46,7 +47,7 @@ const os = require('os'); const tui = require('./tui'); const chalk = tui.chalk; const { loadConfig: loadConfigModule, checkEndpoint } = require('./config'); -const { TOOLS, COMPOUND_TOOLS, getAllTools: _getAllToolsModule } = require('./tools'); +const { TOOLS, COMPOUND_TOOLS, PROVIDER_TOOLS, getAllTools: _getAllToolsModule } = require('./tools'); const { runValidation: _runValidationModule } = require('./model_client'); const { mcpCall, initCodeGraph, killMCP, getMcpProcess } = require('./mcp_bridge'); const { executeTool: _executeToolModule } = require('./executor'); @@ -435,7 +436,7 @@ function getAllTools(config, stage2Category) { return tools; } } -let ALL_TOOLS = [...TOOLS, ...COMPOUND_TOOLS]; +let ALL_TOOLS = [...TOOLS, ...COMPOUND_TOOLS, ...PROVIDER_TOOLS]; const MAX_TOOL_CALLS = 500; const MAX_IMPROVE_ITERATIONS = 2; @@ -1909,7 +1910,7 @@ async function chatCompletion(config, messages) { // Build headers — include Authorization if an API key is available const headers = { 'Content-Type': 'application/json' }; - const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey; + const apiKey = process.env.SMALLCODE_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey; if (apiKey) { headers['Authorization'] = `Bearer ${apiKey}`; } @@ -1965,6 +1966,67 @@ async function chatCompletion(config, messages) { } }; + // Plugin-registered providers: call directly, bypass fetch + const { providerRegistry } = require('../src/compiled/providers/registry'); + const pluginProvider = providerRegistry.get(config.model.provider); + if (pluginProvider) { + _stopSpinner(); + try { + const chatResp = await pluginProvider.chat({ + model: body.model, + messages: body.messages, + temperature: body.temperature, + maxOutput: body.max_tokens, + tools: body.tools, + }, controller.signal); + clearTimeout(timeout); + + // Translate ChatResponse → OpenAI-compatible format for downstream consumers + const data = { + choices: [{ + message: { + role: 'assistant', + content: chatResp.content, + tool_calls: chatResp.tool_calls || [], + }, + finish_reason: chatResp.tool_calls?.length ? 'tool_calls' : 'stop', + }], + usage: chatResp.usage ? { + prompt_tokens: chatResp.usage.promptTokens, + completion_tokens: chatResp.usage.completionTokens, + total_tokens: chatResp.usage.totalTokens, + } : undefined, + }; + + if (tokenTracker && data.usage) { + tokenTracker.record(data, config.model.name); + } + if (data.usage) { + tokenMonitor.recordCall(data.usage.prompt_tokens, data.usage.completion_tokens); + traceRecorder.recordTokens(data.usage.prompt_tokens, data.usage.completion_tokens); + if (chargeBudget) { + try { chargeBudget('run_turn', { tokens: (data.usage.prompt_tokens || 0) + (data.usage.completion_tokens || 0) }); } catch {} + } + } + return data; + } catch (pluginErr) { + clearTimeout(timeout); + const msg = pluginErr.message || 'Plugin provider failed'; + console.log(` \x1b[31m✗ Plugin provider "${config.model.provider}": ${msg}\x1b[0m`); + if (_fullscreenRef) _fullscreenRef.addTool('error', 'err', `${config.model.provider}: ${msg.slice(0, 80)}`); + return null; + } + } + + // Plugin hook: pre_request + if (pluginLoader) { + await pluginLoader.runHooks('pre_request', { + provider: config.model.provider, + model: body.model || config.model.name, + messages: processedMessages, + }); + } + let response; try { response = await fetch(`${baseUrl}/chat/completions`, { @@ -1992,6 +2054,14 @@ async function chatCompletion(config, messages) { console.log(` \x1b[31m✗ Endpoint error: ${errMsg}${hint}\x1b[0m`); if (_fullscreenRef) _fullscreenRef.addTool('error', 'err', `${errMsg.slice(0, 80)}${hint}`); } + // Plugin hook: on_error + if (pluginLoader) { + await pluginLoader.runHooks('on_error', { + provider: config.model.provider, + model: body.model || config.model.name, + error: fetchErr, + }).catch(() => {}); + } return null; } clearTimeout(timeout); @@ -2021,6 +2091,16 @@ async function chatCompletion(config, messages) { const data = await response.json(); + // Plugin hook: post_request + if (pluginLoader) { + await pluginLoader.runHooks('post_request', { + provider: config.model.provider, + model: body.model || config.model.name, + response: data, + usage: data?.usage || null, + }).catch(() => {}); + } + // Track token usage if (tokenTracker && data?.usage) { tokenTracker.record(data, config.model.name); @@ -2065,7 +2145,7 @@ async function streamFinalResponse(config, messages) { try { const headers = { 'Content-Type': 'application/json' }; - const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey; + const apiKey = process.env.SMALLCODE_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey; if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; if (baseUrl.includes('openrouter.ai')) { headers['HTTP-Referer'] = 'https://github.com/Doorman11991/smallcode'; @@ -2183,7 +2263,7 @@ Rules: if (config.model.provider === 'openai' || baseUrl.includes('/v1')) { try { const headers = { 'Content-Type': 'application/json' }; - const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey; + const apiKey = process.env.SMALLCODE_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey; if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; if (baseUrl.includes('openrouter.ai')) { headers['HTTP-Referer'] = 'https://github.com/Doorman11991/smallcode'; @@ -2462,17 +2542,69 @@ async function handleMCPToolCall(id, params) { return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: result }] }}; } +// ─── Minimal TUI (no model — plugin commands only) ────────────────────────── + +async function startMinimalTUI() { + const readline = require('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: chalk.cyan('smallcode> '), + }); + + const createCommandHandler = require('./commands'); + const handleCmd = createCommandHandler(config, [], 0, null, null, 0, null, escalationEngine, null); + + rl.prompt(); + + rl.on('line', async (line) => { + const input = line.trim(); + if (!input) { rl.prompt(); return; } + + if (input === '/exit' || input === '/quit') { + console.log(chalk.gray('\n Goodbye.\n')); + rl.close(); + process.exit(0); + } + + if (input.startsWith('/')) { + await handleCmd(input, rl); + return; + } + + console.log(chalk.gray(' No model configured. Type /provider to set up, or /exit to quit.')); + rl.prompt(); + }); + + rl.on('close', () => process.exit(0)); +} + // ─── Main ──────────────────────────────────────────────────────────────────── async function main() { config = loadConfig(); + // Initialize plugins early so they can handle setup (e.g. /provider wizard) + pluginLoader = new PluginLoader(process.cwd()).loadAll(); + skillManager = new SkillManager(process.cwd()); + // Check model is configured if (!config.model.name) { - console.error('\n ✗ No model configured.'); - console.error(' Set SMALLCODE_MODEL in .env, or add [model] name = "..." to smallcode.toml'); - console.error(' See .env.example for setup instructions.\n'); - process.exit(1); + // Allow /provider commands even without a model configured + const providerArg = positional.find(a => a.startsWith('/provider') || a === 'provider'); + if (providerArg) { + const cmd = providerArg.startsWith('/') ? providerArg : '/provider'; + const rest = positional.filter(a => a !== providerArg).join(' '); + const createCommandHandler = require('./commands'); + const handleCmd = createCommandHandler(config, [], 0, null, null, 0, null, null, null); + const mockRl = { prompt: () => {}, close: () => {}, on: () => {}, question: (q, cb) => cb('') }; + await handleCmd(rest ? `${cmd} ${rest}` : cmd, mockRl); + return; + } + console.log('\n ⚡ SmallCode — no model configured.\n'); + console.log(' Type /provider to configure a model, or /provider status to check.\n'); + startMinimalTUI(); + return; } // Initialize escalation engine @@ -2489,6 +2621,13 @@ async function main() { // Initialize plugins and skills pluginLoader = new PluginLoader(process.cwd()).loadAll(); + await pluginLoader.runInit({ config, cwd: process.cwd() }); + + // Run plugin shutdown handlers on exit + process.on('beforeExit', () => { + if (pluginLoader) pluginLoader.runShutdown({ config, cwd: process.cwd() }).catch(() => {}); + }); + skillManager = new SkillManager(process.cwd()); // Initialize MCP client (connect to external MCP servers) @@ -2554,6 +2693,18 @@ async function main() { return; } + // Handle /provider even when model IS configured (must come before positional prompt) + const providerArg = positional.find(a => a.startsWith('/provider') || a === 'provider'); + if (providerArg) { + const cmd = providerArg.startsWith('/') ? providerArg : '/provider'; + const rest = positional.filter(a => a !== providerArg).join(' '); + const createCommandHandler = require('./commands'); + const handleCmd = createCommandHandler(config, [], 0, null, null, 0, null, null, null); + const mockRl = { prompt: () => {}, close: () => {}, on: () => {}, question: (q, cb) => cb('') }; + await handleCmd(rest ? `${cmd} ${rest}` : cmd, mockRl); + return; + } + if (flags.nonInteractive || flags.prompt || positional.length > 0) { const prompt = flags.prompt || positional.join(' '); await runNonInteractive(config, prompt); diff --git a/bin/tools.js b/bin/tools.js index a1a691d1..ee8d7bd8 100644 --- a/bin/tools.js +++ b/bin/tools.js @@ -27,6 +27,12 @@ const TOOLS = [ { type: 'function', function: { name: 'memory_forget', description: 'Delete a memory object by ID.', parameters: { type: 'object', properties: { id: { type: 'string', description: 'Memory object ID to delete' } }, required: ['id'] } } }, ]; +// ─── Provider Tools ───────────────────────────────────────────────────────── +const PROVIDER_TOOLS = [ + { type: 'function', function: { name: 'configure_provider', description: 'Configure a provider: set provider type, base URL, model name, API key, and optional escalation fallback. Call without arguments to start the interactive wizard.', parameters: { type: 'object', properties: { provider: { type: 'string', description: 'Provider name: lmstudio, ollama, openrouter, openai, anthropic, deepseek, custom' }, baseUrl: { type: 'string', description: 'Provider base URL (e.g. http://localhost:1234/v1)' }, model: { type: 'string', description: 'Model name to use (e.g. llama-3.1-8b-instruct)' }, apiKey: { type: 'string', description: 'API key (optional, some providers require it)' }, escalationProvider: { type: 'string', description: 'Escalation fallback provider name (optional)' }, escalationModel: { type: 'string', description: 'Escalation fallback model name (optional)' } }, required: [] } } }, + { type: 'function', function: { name: 'provider_status', description: 'Show current provider configuration status: which provider is active, base URL, model, escalation settings, and whether an API key is set.', parameters: { type: 'object', properties: {}, required: [] } } }, +]; + // ─── Compound Tools ────────────────────────────────────────────────────────── const COMPOUND_TOOLS = [ @@ -48,7 +54,7 @@ const COMPOUND_TOOLS = [ function getAllTools(config, stage2Category, deps = {}) { const pluginTools = deps.pluginLoader ? deps.pluginLoader.getTools() : []; const mcpTools = deps.mcpClient ? deps.mcpClient.getToolDefs() : []; - const allTools = [...TOOLS, ...COMPOUND_TOOLS, ...pluginTools, ...mcpTools]; + const allTools = [...TOOLS, ...COMPOUND_TOOLS, ...PROVIDER_TOOLS, ...pluginTools, ...mcpTools]; // If a deterministic tool category was pre-classified, filter tools // This skips the LLM-based two_stage routing entirely @@ -94,4 +100,4 @@ function getAllTools(config, stage2Category, deps = {}) { return allTools; } -module.exports = { TOOLS, COMPOUND_TOOLS, getAllTools }; +module.exports = { TOOLS, COMPOUND_TOOLS, PROVIDER_TOOLS, getAllTools }; diff --git a/package-lock.json b/package-lock.json index 936d73de..7012edc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "smallcode", - "version": "0.6.14", + "version": "0.9.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "smallcode", - "version": "0.6.14", + "version": "0.9.6", "license": "MIT", "dependencies": { "bonescript-compiler": "0.14.0", diff --git a/src/compiled/providers/index.js b/src/compiled/providers/index.js index 9e0c23d6..b365704d 100644 --- a/src/compiled/providers/index.js +++ b/src/compiled/providers/index.js @@ -6,7 +6,9 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.MODELS = void 0; exports.getModel = getModel; exports.listModelNames = listModelNames; +exports.resolveProvider = resolveProvider; const openai_compat_1 = require("./openai_compat"); +const registry_1 = require("./registry"); // Substitute ${ENV_VAR} placeholders in declared values with runtime env vars. function _sub(s) { return s.replace(/\$\{(\w+)\}/g, (_, k) => process.env[k] || ''); @@ -86,3 +88,17 @@ function listModelNames() { _models = _buildModels(); return Object.keys(_models).sort(); } +/** + * Resolve a provider by name. Checks the plugin registry first; + * falls back to creating a default OpenAICompatProvider with the + * configured baseUrl. + */ +function resolveProvider(name) { + const fromRegistry = registry_1.providerRegistry.get(name); + if (fromRegistry) + return fromRegistry; + const baseUrl = _sub("${SMALLCODE_BASE_URL}") || "http://localhost:1234/v1"; + const provider = new openai_compat_1.OpenAICompatProvider(baseUrl); + registry_1.providerRegistry.register(name, provider); + return provider; +} diff --git a/src/compiled/providers/index.ts b/src/compiled/providers/index.ts index 36f8e720..99553bd4 100644 --- a/src/compiled/providers/index.ts +++ b/src/compiled/providers/index.ts @@ -4,6 +4,7 @@ import type { IModelProvider } from "./types"; import { OpenAICompatProvider } from "./openai_compat"; +import { providerRegistry } from "./registry"; // Substitute ${ENV_VAR} placeholders in declared values with runtime env vars. function _sub(s: string): string { @@ -99,3 +100,17 @@ export function listModelNames(): string[] { if (!_models) _models = _buildModels(); return Object.keys(_models).sort(); } + +/** + * Resolve a provider by name. Checks the plugin registry first; + * falls back to creating a default OpenAICompatProvider with the + * configured baseUrl. + */ +export function resolveProvider(name: string): IModelProvider { + const fromRegistry = providerRegistry.get(name); + if (fromRegistry) return fromRegistry; + const baseUrl = _sub("${SMALLCODE_BASE_URL}") || "http://localhost:1234/v1"; + const provider = new OpenAICompatProvider(baseUrl); + providerRegistry.register(name, provider); + return provider; +} diff --git a/src/compiled/providers/openai_compat.js b/src/compiled/providers/openai_compat.js index bd745c98..63038d54 100644 --- a/src/compiled/providers/openai_compat.js +++ b/src/compiled/providers/openai_compat.js @@ -5,6 +5,7 @@ // shim, and OpenAI itself when API key is set. Object.defineProperty(exports, "__esModule", { value: true }); exports.OpenAICompatProvider = void 0; +exports.default = void 0; const types_1 = require("./types"); const ssrf_guard_1 = require("./ssrf_guard"); class OpenAICompatProvider { @@ -91,6 +92,7 @@ class OpenAICompatProvider { } } exports.OpenAICompatProvider = OpenAICompatProvider; +exports.default = OpenAICompatProvider; /** * Map a sequence of per-token logprobs to a single 0..1 confidence value. * We use exp(mean(logprob)) — the geometric mean of per-token probabilities. diff --git a/src/compiled/providers/openai_compat.ts b/src/compiled/providers/openai_compat.ts index d28036dd..97b67d04 100644 --- a/src/compiled/providers/openai_compat.ts +++ b/src/compiled/providers/openai_compat.ts @@ -7,7 +7,7 @@ import type { ChatRequest, ChatResponse, ChatToolCall, IModelProvider } from "./ import { approxTokens } from "./types"; import { assertEndpointAllowed } from "./ssrf_guard"; -export class OpenAICompatProvider implements IModelProvider { +export default class OpenAICompatProvider implements IModelProvider { readonly name = "openai_compat"; private endpoint: string; private apiKey: string; diff --git a/src/compiled/providers/prompt_inject.js b/src/compiled/providers/prompt_inject.js new file mode 100644 index 00000000..dede7897 --- /dev/null +++ b/src/compiled/providers/prompt_inject.js @@ -0,0 +1,52 @@ +"use strict"; +// prompt_inject — wraps any IModelProvider and injects content into system messages. +// Used by the prompt-inject plugin to add custom instructions, RAG context, +// or persona content to every LLM call without touching the core runtime. +Object.defineProperty(exports, "__esModule", { value: true }); +const types_1 = require("./types"); +class PromptInjectProvider { + constructor(options) { + this.name = "prompt_inject"; + this.innerInstance = null; + this.innerName = options.inner; + this.injections = options.injections || []; + } + getInner() { + if (!this.innerInstance) { + const { providerRegistry } = require("./registry"); + this.innerInstance = providerRegistry.get(this.innerName); + if (!this.innerInstance) { + throw new Error(`prompt_inject: inner provider "${this.innerName}" not found in registry`); + } + } + return this.innerInstance; + } + countTokens(text) { + return (0, types_1.approxTokens)(text); + } + async chat(req, signal) { + const messages = req.messages.map(m => ({ ...m })); + const injectedContent = this.injections.map(i => i.content).join("\n\n"); + const position = this.injections[this.injections.length - 1]?.position ?? "append"; + for (let i = 0; i < messages.length; i++) { + if (messages[i].role === "system") { + if (position === "replace") { + messages[i] = { ...messages[i], content: injectedContent }; + } + else if (position === "prepend") { + messages[i] = { ...messages[i], content: `${injectedContent}\n\n${messages[i].content}` }; + } + else { + messages[i] = { ...messages[i], content: `${messages[i].content}\n\n${injectedContent}` }; + } + break; + } + } + if (!messages.some(m => m.role === "system")) { + messages.unshift({ role: "system", content: injectedContent }); + } + return this.getInner().chat({ ...req, messages }, signal); + } +} +exports.default = PromptInjectProvider; +module.exports = PromptInjectProvider; diff --git a/src/compiled/providers/prompt_inject.ts b/src/compiled/providers/prompt_inject.ts new file mode 100644 index 00000000..6dfef538 --- /dev/null +++ b/src/compiled/providers/prompt_inject.ts @@ -0,0 +1,72 @@ +// prompt_inject — wraps any IModelProvider and injects content into system messages. +// Used by the prompt-inject plugin to add custom instructions, RAG context, +// or persona content to every LLM call without touching the core runtime. + +/* eslint-disable @typescript-eslint/no-require-imports */ +import type { ChatRequest, ChatResponse, IModelProvider } from "./types"; +import { approxTokens } from "./types"; +declare const require: (id: string) => any; + +export type InjectionPosition = "prepend" | "append" | "replace"; + +export interface PromptInjection { + position?: InjectionPosition; + content: string; +} + +export interface PromptInjectOptions { + inner: string; + injections: PromptInjection[]; +} + +export default class PromptInjectProvider implements IModelProvider { + readonly name = "prompt_inject"; + private innerName: string; + private innerInstance: IModelProvider | null = null; + private injections: PromptInjection[]; + + constructor(options: PromptInjectOptions) { + this.innerName = options.inner; + this.injections = options.injections || []; + } + + private getInner(): IModelProvider { + if (!this.innerInstance) { + const { providerRegistry } = require("./registry"); + this.innerInstance = providerRegistry.get(this.innerName); + if (!this.innerInstance) { + throw new Error(`prompt_inject: inner provider "${this.innerName}" not found in registry`); + } + } + return this.innerInstance; + } + + countTokens(text: string): number { + return approxTokens(text); + } + + async chat(req: ChatRequest, signal?: AbortSignal): Promise { + const messages = req.messages.map(m => ({ ...m })); + const injectedContent = this.injections.map(i => i.content).join("\n\n"); + const position = this.injections[this.injections.length - 1]?.position ?? "append"; + + for (let i = 0; i < messages.length; i++) { + if (messages[i].role === "system") { + if (position === "replace") { + messages[i] = { ...messages[i], content: injectedContent }; + } else if (position === "prepend") { + messages[i] = { ...messages[i], content: `${injectedContent}\n\n${messages[i].content}` }; + } else { + messages[i] = { ...messages[i], content: `${messages[i].content}\n\n${injectedContent}` }; + } + break; + } + } + + if (!messages.some(m => m.role === "system")) { + messages.unshift({ role: "system", content: injectedContent }); + } + + return this.getInner().chat({ ...req, messages }, signal); + } +} diff --git a/src/compiled/providers/registry.js b/src/compiled/providers/registry.js new file mode 100644 index 00000000..2d88aa06 --- /dev/null +++ b/src/compiled/providers/registry.js @@ -0,0 +1,45 @@ +"use strict"; +// Plugin-provider registry. +// Plugins register named providers at load time; the runtime resolves them +// by name via resolveProvider() in index.ts. If no plugin provider is +// registered, the default OpenAI-compatible adapter is used. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.providerRegistry = exports.ProviderRegistry = void 0; + +const DEFAULT_CAPABILITIES = { + tools: true, + streaming: true, + vision: false, + tokenCounting: "heuristic", +}; + +class ProviderRegistry { + constructor() { + this.providers = new Map(); + this.capabilities = new Map(); + } + + register(name, provider, caps) { + this.providers.set(name, provider); + this.capabilities.set(name, { ...DEFAULT_CAPABILITIES, ...caps }); + } + + get(name) { + return this.providers.get(name); + } + + has(name) { + return this.providers.has(name); + } + + list() { + return [...this.providers.keys()]; + } + + getCapabilities(name) { + return this.capabilities.get(name) ?? DEFAULT_CAPABILITIES; + } +} + +exports.ProviderRegistry = ProviderRegistry; +exports.providerRegistry = new ProviderRegistry(); diff --git a/src/compiled/providers/registry.ts b/src/compiled/providers/registry.ts new file mode 100644 index 00000000..286fc9fe --- /dev/null +++ b/src/compiled/providers/registry.ts @@ -0,0 +1,52 @@ +// Plugin-provider registry. +// Plugins register named providers at load time; the runtime resolves them +// by name via resolveProvider() in index.ts. If no plugin provider is +// registered, the default OpenAI-compatible adapter is used. + +import type { IModelProvider } from "./types"; + +export interface ProviderCapabilities { + tools: boolean; + streaming: boolean; + vision: boolean; + tokenCounting: "exact" | "heuristic" | "none"; +} + +const DEFAULT_CAPABILITIES: ProviderCapabilities = { + tools: true, + streaming: true, + vision: false, + tokenCounting: "heuristic", +}; + +class ProviderRegistry { + private providers = new Map(); + private capabilities = new Map(); + + register( + name: string, + provider: IModelProvider, + caps?: Partial, + ): void { + this.providers.set(name, provider); + this.capabilities.set(name, { ...DEFAULT_CAPABILITIES, ...caps }); + } + + get(name: string): IModelProvider | undefined { + return this.providers.get(name); + } + + has(name: string): boolean { + return this.providers.has(name); + } + + list(): string[] { + return [...this.providers.keys()]; + } + + getCapabilities(name: string): ProviderCapabilities { + return this.capabilities.get(name) ?? DEFAULT_CAPABILITIES; + } +} + +export const providerRegistry = new ProviderRegistry(); diff --git a/src/plugins/loader.js b/src/plugins/loader.js index e3db34d0..f34180ef 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -19,12 +19,18 @@ // "tools": [{ "name": "...", "description": "...", "parameters": {...}, "handler": "./handler.js" }], // "prompts": [{ "inject": "always|backend|coding", "content": "..." }], // "commands": [{ "name": "/mycmd", "description": "...", "handler": "./cmd.js" }], -// "hooks": [{ "event": "post_tool", "filter": ["write_file"], "handler": "./hook.js" }] +// "hooks": [{ "event": "pre_request|post_request|on_error|session_start|session_end|post_tool", "filter": ["write_file"], "handler": "./hook.js" }], +// "init": "./init.js", +// "shutdown": "./cleanup.js", +// "providers": [{ "name": "...", "module": "./adapter.js", "options": {}, "capabilities": { "tools": true, "streaming": true } }], +// "permissions": { "read": true, "write": true, "execute": false, "network": true }, +// "mcpServers": { "my-server": { "command": "./server.js", "args": [], "transport": "stdio" } } // } const fs = require('fs'); const path = require('path'); const os = require('os'); +const { providerRegistry } = require('../compiled/providers/registry'); class PluginLoader { constructor(projectDir) { @@ -34,12 +40,19 @@ class PluginLoader { this.commands = {}; // /command → handler this.prompts = []; // System prompt injections this.hooks = []; // Event hooks + this.providers = {}; // name → IModelProvider instance + this.initHandlers = []; // async init handlers from plugin manifests + this.shutdownHandlers = []; // async shutdown handlers from plugin manifests + this.permissions = {}; // plugin name → { read, write, execute, network } + this.mcpServers = {}; // plugin name → { serverName: { command, args, transport } } + this.errors = []; // { dir, message } for diagnostics } - // Load all plugins from project + user dirs + // Load all plugins from project, user, and global dirs loadAll() { const dirs = [ path.join(this.projectDir, '.smallcode', 'plugins'), + path.join(os.homedir(), '.smallcode', 'plugins'), path.join(os.homedir(), '.config', 'smallcode', 'plugins'), ]; @@ -47,7 +60,9 @@ class PluginLoader { if (!fs.existsSync(dir)) continue; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { - if (entry.isDirectory()) { + // isDirectory() doesn't follow symlinks — check stat for symlink dirs + const isDir = entry.isDirectory() || (entry.isSymbolicLink() && fs.statSync(path.join(dir, entry.name)).isDirectory()); + if (isDir) { this._loadPlugin(path.join(dir, entry.name)); } else if (entry.name.endsWith('.json') && entry.name !== 'package.json') { // Single-file plugin (just a manifest with inline content) @@ -122,7 +137,13 @@ class PluginLoader { // Register hooks if (manifest.hooks) { + const validEvents = ['pre_tool', 'post_tool', 'session_start', 'session_end', + 'pre_request', 'post_request', 'on_error']; for (const h of manifest.hooks) { + if (!validEvents.includes(h.event)) { + console.warn(`[plugin:${plugin.name}] Unknown hook event "${h.event}", skipping`); + continue; + } const handlerPath = path.resolve(pluginDir, h.handler || './hook.js'); let handler = null; if (fs.existsSync(handlerPath)) { @@ -137,9 +158,89 @@ class PluginLoader { } } + // Register init handler + if (manifest.init) { + const initPath = path.resolve(pluginDir, manifest.init); + if (fs.existsSync(initPath)) { + try { + const initHandler = require(initPath); + this.initHandlers.push({ + handler: initHandler.default || initHandler, + plugin: plugin.name, + }); + } catch (e) { + console.error(`[plugin:${plugin.name}] Failed to load init: ${e.message}`); + } + } + } + + // Register shutdown handler + if (manifest.shutdown) { + const shutdownPath = path.resolve(pluginDir, manifest.shutdown); + if (fs.existsSync(shutdownPath)) { + try { + const shutdownHandler = require(shutdownPath); + this.shutdownHandlers.push({ + handler: shutdownHandler.default || shutdownHandler, + plugin: plugin.name, + }); + } catch (e) { + console.error(`[plugin:${plugin.name}] Failed to load shutdown: ${e.message}`); + } + } + } + + // Register providers + if (manifest.providers) { + for (const spec of manifest.providers) { + try { + const modulePath = path.resolve(pluginDir, spec.module); + const Export = require(modulePath); + const ProviderClass = Export.default || Export; + const instance = new ProviderClass(spec.options || {}); + if (!instance.chat || !instance.name) { + throw new Error(`Provider "${spec.name}" must implement .chat() and .name`); + } + const caps = spec.capabilities || {}; + providerRegistry.register(spec.name, instance, caps); + this.providers[spec.name] = instance; + } catch (e) { + const msg = `Failed to load provider "${spec.name}": ${e.message}`; + console.error(`[plugin:${plugin.name}] ${msg}`); + this.errors.push({ dir: pluginDir, message: msg }); + } + } + } + + // Register permissions + if (manifest.permissions) { + this.permissions[plugin.name] = { + read: !!manifest.permissions.read, + write: !!manifest.permissions.write, + execute: !!manifest.permissions.execute, + network: !!manifest.permissions.network, + }; + } else { + // Default: read-only, no write/execute/network + this.permissions[plugin.name] = { read: true, write: false, execute: false, network: false }; + } + + // Register MCP server declarations + if (manifest.mcpServers) { + this.mcpServers[plugin.name] = {}; + for (const [serverName, serverDef] of Object.entries(manifest.mcpServers)) { + this.mcpServers[plugin.name][serverName] = { + command: serverDef.command, + args: serverDef.args || [], + transport: serverDef.transport || 'stdio', + }; + } + } + this.plugins.push(plugin); } catch (e) { - // Silently skip broken plugins + // Store error for diagnostics, but don't crash + this.errors.push({ dir: pluginDir, message: e.message }); } } @@ -203,6 +304,33 @@ class PluginLoader { return null; } + // Get permissions for a plugin + getPermissions(pluginName) { + return this.permissions[pluginName] || null; + } + + // Check if a plugin has a specific permission + hasPermission(pluginName, perm) { + const p = this.permissions[pluginName]; + return p ? !!p[perm] : false; + } + + // Get all MCP server declarations across plugins + getMCPServers() { + const servers = {}; + for (const [plugin, pluginServers] of Object.entries(this.mcpServers)) { + for (const [name, def] of Object.entries(pluginServers)) { + servers[`${plugin}/${name}`] = def; + } + } + return servers; + } + + // Get error diagnostics for failed plugin loads + getErrors() { + return this.errors; + } + // List all plugins for display list() { return this.plugins.map(p => ({ @@ -213,6 +341,53 @@ class PluginLoader { commands: Object.keys(this.commands).filter(k => this.commands[k].plugin === p.name), })); } + + // Run all plugin init handlers. Called once at startup after loadAll(). + async runInit(context = {}) { + for (const { handler, plugin } of this.initHandlers) { + try { + await handler(context); + } catch (e) { + console.error(`[plugin:${plugin}] init failed: ${e.message}`); + } + } + } + + // Run all plugin shutdown handlers. Called on exit for cleanup. + async runShutdown(context = {}) { + for (const { handler, plugin } of this.shutdownHandlers) { + try { + await handler(context); + } catch (e) { + console.error(`[plugin:${plugin}] shutdown failed: ${e.message}`); + } + } + } + + // Execute hooks for a given event. Returns array of results from non-void handlers. + async runHooks(event, data = {}) { + const results = []; + for (const hook of this.hooks) { + if (hook.event !== event) continue; + if (hook.filter.length > 0 && !hook.filter.includes(data.toolName || '')) continue; + if (!hook.handler) continue; + + // For post_tool hooks, handler is { after(toolResult, ctx) } + // For new event hooks, handler is { handle(data) } or a plain function + try { + if (hook.handler.handle) { + const result = await hook.handler.handle(data); + if (result !== undefined) results.push(result); + } else if (typeof hook.handler === 'function') { + const result = await hook.handler(data); + if (result !== undefined) results.push(result); + } + } catch (e) { + console.error(`[plugin:${plugin}] hook ${event} failed: ${e.message}`); + } + } + return results; + } } module.exports = { PluginLoader };