From 173310a5219d3a072bc8b1ac3806b111fa77c56f Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 10:21:46 -0500 Subject: [PATCH 01/16] feat: ProviderRegistry + plugin provider wiring for chat/escalation Implements the core plugin provider infrastructure: - Add ProviderRegistry singleton for named provider lookup - Wire plugin providers into chatCompletion and escalation flows - Support plugin manifest `providers` field in loader - Register openai_compat and ollama providers on startup Co-Authored-By: Claude Opus 4.7 --- bin/config.js | 7 ++++ bin/escalation.js | 17 ++++++++ bin/smallcode.js | 52 +++++++++++++++++++++++++ package-lock.json | 4 +- src/compiled/providers/index.js | 16 ++++++++ src/compiled/providers/index.ts | 15 +++++++ src/compiled/providers/openai_compat.js | 2 + src/compiled/providers/openai_compat.ts | 2 +- src/compiled/providers/registry.js | 45 +++++++++++++++++++++ src/compiled/providers/registry.ts | 52 +++++++++++++++++++++++++ src/plugins/loader.js | 36 ++++++++++++++++- 11 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 src/compiled/providers/registry.js create mode 100644 src/compiled/providers/registry.ts diff --git a/bin/config.js b/bin/config.js index 9a0890bf..fad02716 100644 --- a/bin/config.js +++ b/bin/config.js @@ -93,6 +93,13 @@ 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 { 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/smallcode.js b/bin/smallcode.js index b11afba2..249702ca 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -1965,6 +1965,58 @@ 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; + } + } + let response; try { response = await fetch(`${baseUrl}/chat/completions`, { 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/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..da6f8205 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -19,12 +19,14 @@ // "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": "post_tool", "filter": ["write_file"], "handler": "./hook.js" }], +// "providers": [{ "name": "...", "module": "./adapter.js", "options": {} }] // } const fs = require('fs'); const path = require('path'); const os = require('os'); +const { providerRegistry } = require('../compiled/providers/registry'); class PluginLoader { constructor(projectDir) { @@ -34,6 +36,8 @@ class PluginLoader { this.commands = {}; // /command → handler this.prompts = []; // System prompt injections this.hooks = []; // Event hooks + this.providers = {}; // name → IModelProvider instance + this.errors = []; // { dir, message } for diagnostics } // Load all plugins from project + user dirs @@ -137,9 +141,32 @@ class PluginLoader { } } + // 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 }); + } + } + } + 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 +230,11 @@ class PluginLoader { return null; } + // Get error diagnostics for failed plugin loads + getErrors() { + return this.errors; + } + // List all plugins for display list() { return this.plugins.map(p => ({ From a9c6b3707a526643dbf3f339347bdc0abc168b63 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 10:22:05 -0500 Subject: [PATCH 02/16] feat: add prompt-inject provider plugin Implements a decorator-style provider that wraps any registered provider and injects custom content into system prompts. Supports prepend, append, and replace positions. Inner provider is lazily resolved from the ProviderRegistry at first chat() call. Co-Authored-By: Claude Opus 4.7 --- .smallcode/plugins/prompt-inject/plugin.json | 12 ++++ src/compiled/providers/prompt_inject.js | 52 ++++++++++++++ src/compiled/providers/prompt_inject.ts | 72 ++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 .smallcode/plugins/prompt-inject/plugin.json create mode 100644 src/compiled/providers/prompt_inject.js create mode 100644 src/compiled/providers/prompt_inject.ts 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/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); + } +} From 03908091f3c0195ba464079dc9c3d4ad0b85e86e Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 10:27:36 -0500 Subject: [PATCH 03/16] feat: lifecycle hooks + 5 new hook events for plugin system Adds plugin init/shutdown lifecycle and hook events for LLM request interception: - `init` / `shutdown` manifest fields for startup/teardown handlers - `pre_request`, `post_request`, `on_error` hook events in chat path - `session_start`, `session_end` events (wired at session boundaries) - `runInit()`, `runShutdown()`, `runHooks()` methods on PluginLoader - Hook event validation with warning for unknown events Co-Authored-By: Claude Opus 4.7 --- bin/smallcode.js | 34 ++++++++++++++++ src/plugins/loader.js | 91 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/bin/smallcode.js b/bin/smallcode.js index 249702ca..fd1900c9 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -2017,6 +2017,15 @@ async function chatCompletion(config, messages) { } } + // 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`, { @@ -2044,6 +2053,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); @@ -2073,6 +2090,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); @@ -2541,6 +2568,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) diff --git a/src/plugins/loader.js b/src/plugins/loader.js index da6f8205..12c90f86 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -19,7 +19,9 @@ // "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": {} }] // } @@ -37,6 +39,8 @@ class PluginLoader { 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.errors = []; // { dir, message } for diagnostics } @@ -126,7 +130,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)) { @@ -141,6 +151,38 @@ 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) { @@ -245,6 +287,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 }; From edd37767d488b1ce64a18d4fa4eb4221b468e0a4 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 10:33:26 -0500 Subject: [PATCH 04/16] feat: plugin manifest permissions, MCP declarations, capabilities Adds support for: - `permissions` manifest field (read/write/execute/network) - `mcpServers` manifest field for declaring bundled MCP servers - `capabilities` field on provider declarations (passed to registry) - getPermissions(), hasPermission(), getMCPServers() accessors - Default permissions: read-only, no write/execute/network Co-Authored-By: Claude Opus 4.7 --- src/plugins/loader.js | 53 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/plugins/loader.js b/src/plugins/loader.js index 12c90f86..71fe73e0 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -22,7 +22,9 @@ // "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": {} }] +// "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'); @@ -41,6 +43,8 @@ class PluginLoader { 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 } @@ -205,6 +209,31 @@ class PluginLoader { } } + // 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) { // Store error for diagnostics, but don't crash @@ -272,6 +301,28 @@ 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; From f27d914905b9647ed6ab4f97da4afcf708f7d4ce Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 12:47:55 -0500 Subject: [PATCH 05/16] fix: export PROVIDER_TOOLS from tools.js PROVIDER_TOOLS was defined but not included in module.exports, causing TypeError: PROVIDER_TOOLS is not iterable in smallcode.js. Co-Authored-By: Claude Opus 4.7 --- bin/tools.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/tools.js b/bin/tools.js index a1a691d1..e12f2d5b 100644 --- a/bin/tools.js +++ b/bin/tools.js @@ -94,4 +94,4 @@ function getAllTools(config, stage2Category, deps = {}) { return allTools; } -module.exports = { TOOLS, COMPOUND_TOOLS, getAllTools }; +module.exports = { TOOLS, COMPOUND_TOOLS, PROVIDER_TOOLS, getAllTools }; From a2416fdcfcbb08188c0053d4e02308d690ef83f1 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 12:17:50 -0500 Subject: [PATCH 06/16] feat: provider wizard plugin + plugin install scopes + symlink fix - Add provider-wizard plugin: /provider command for interactive setup of LLM providers (LM Studio, Ollama, OpenRouter, OpenAI, Anthropic, DeepSeek, custom). Includes /provider status tool and configure tool with validation and discovery. - Add plugin install scopes (project/user/global) via --scope flag with per-scope add/remove commands - Fix plugin command dispatch: strip leading slash before lookup - Fix symlink handling in plugin loader: readdirSync with withFileTypes doesn't follow symlinks, so isDirectory() returns false for them Co-Authored-By: Claude Sonnet 4.6 --- .smallcode/plugins/provider-wizard/cmd.js | 20 ++ .../plugins/provider-wizard/plugin.json | 62 ++++ .smallcode/plugins/provider-wizard/status.js | 112 +++++++ .../plugins/provider-wizard/tool-configure.js | 29 ++ .../plugins/provider-wizard/tool-status.js | 7 + .smallcode/plugins/provider-wizard/wizard.js | 302 ++++++++++++++++++ bin/commands.js | 81 +++-- bin/init.js | 0 bin/smallcode.js | 48 +++ src/plugins/loader.js | 7 +- 10 files changed, 638 insertions(+), 30 deletions(-) create mode 100644 .smallcode/plugins/provider-wizard/cmd.js create mode 100644 .smallcode/plugins/provider-wizard/plugin.json create mode 100644 .smallcode/plugins/provider-wizard/status.js create mode 100644 .smallcode/plugins/provider-wizard/tool-configure.js create mode 100644 .smallcode/plugins/provider-wizard/tool-status.js create mode 100644 .smallcode/plugins/provider-wizard/wizard.js mode change 100644 => 100755 bin/init.js diff --git a/.smallcode/plugins/provider-wizard/cmd.js b/.smallcode/plugins/provider-wizard/cmd.js new file mode 100644 index 00000000..3583f9a6 --- /dev/null +++ b/.smallcode/plugins/provider-wizard/cmd.js @@ -0,0 +1,20 @@ +// Provider Wizard — /provider slash command handler + +const { runWizard } = require('./wizard'); +const { getStatus, formatStatus } = require('./status'); + +module.exports = async function providerCmd(args) { + const arg = (args || '').trim().toLowerCase(); + + if (arg === 'status' || arg === '--status' || arg === '-s') { + const status = getStatus(); + return '\n \x1b[1;36mProvider Status\x1b[0m\n' + formatStatus(status) + '\n'; + } + + const result = await runWizard({ interactive: true }); + + if (result.success) { + return ''; // wizard already printed the summary + } + return ` \x1b[31mFailed:\x1b[0m ${result.error}`; +}; diff --git a/.smallcode/plugins/provider-wizard/plugin.json b/.smallcode/plugins/provider-wizard/plugin.json new file mode 100644 index 00000000..dfa9442c --- /dev/null +++ b/.smallcode/plugins/provider-wizard/plugin.json @@ -0,0 +1,62 @@ +{ + "name": "provider-wizard", + "version": "0.1.0", + "description": "Interactive wizard to configure LLM providers, API keys, models, and escalation", + "commands": [ + { + "name": "provider", + "handler": "./cmd.js" + } + ], + "tools": [ + { + "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": "Base URL for the provider API" + }, + "model": { + "type": "string", + "description": "Model name to use" + }, + "apiKey": { + "type": "string", + "description": "API key for the provider" + }, + "escalationProvider": { + "type": "string", + "description": "Optional escalation/fallback provider" + }, + "escalationModel": { + "type": "string", + "description": "Model for the escalation provider" + } + } + }, + "handler": "./tool-configure.js" + }, + { + "name": "provider_status", + "description": "Show current provider configuration status: which providers are configured, API keys present, models, and escalation setup.", + "parameters": { + "type": "object", + "properties": {} + }, + "handler": "./tool-status.js" + } + ], + "prompts": [ + { + "inject": "always", + "content": "The user has a /provider command to configure LLM providers. Available providers: LM Studio, Ollama, OpenRouter, OpenAI, Anthropic, DeepSeek, and custom endpoints. Use the configure_provider tool to help set up providers programmatically, or suggest the user run /provider for the interactive wizard." + } + ] +} diff --git a/.smallcode/plugins/provider-wizard/status.js b/.smallcode/plugins/provider-wizard/status.js new file mode 100644 index 00000000..5646750c --- /dev/null +++ b/.smallcode/plugins/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: null }, +}; + +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/.smallcode/plugins/provider-wizard/tool-configure.js b/.smallcode/plugins/provider-wizard/tool-configure.js new file mode 100644 index 00000000..9f270a0d --- /dev/null +++ b/.smallcode/plugins/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/.smallcode/plugins/provider-wizard/tool-status.js b/.smallcode/plugins/provider-wizard/tool-status.js new file mode 100644 index 00000000..daa7217f --- /dev/null +++ b/.smallcode/plugins/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/.smallcode/plugins/provider-wizard/wizard.js b/.smallcode/plugins/provider-wizard/wizard.js new file mode 100644 index 00000000..0527e0ed --- /dev/null +++ b/.smallcode/plugins/provider-wizard/wizard.js @@ -0,0 +1,302 @@ +// 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 { 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) { + 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 + + try { + const url = `${info.defaultUrl}/models`; + const res = await fetch(url, { + 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'); + + // 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); + 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 .env + const envVars = { + SMALLCODE_PROVIDER: provider, + SMALLCODE_BASE_URL: baseUrl, + SMALLCODE_MODEL: model, + }; + if (providerInfo.keyEnv && apiKey) { + envVars[providerInfo.keyEnv] = apiKey; + } + + 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/commands.js b/bin/commands.js index 0db4541e..9b46afc8 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(); @@ -754,11 +778,12 @@ module.exports = function createCommandHandler(config, conversationHistory, impr 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/init.js b/bin/init.js old mode 100644 new mode 100755 diff --git a/bin/smallcode.js b/bin/smallcode.js index fd1900c9..6de8a739 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -2541,13 +2541,61 @@ 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) { + // If a provider plugin command is available, boot minimal TUI for setup + if (pluginLoader.commands['provider']) { + console.log('\n ⚡ SmallCode — no model configured.\n'); + console.log(' Type /provider to configure a model, or /provider status to check.\n'); + startMinimalTUI(); + return; + } 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'); diff --git a/src/plugins/loader.js b/src/plugins/loader.js index 71fe73e0..f34180ef 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -48,10 +48,11 @@ class PluginLoader { 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'), ]; @@ -59,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) From 9e9da58f3cc5c82d510a09c119f55ba92b9a72f6 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 12:43:03 -0500 Subject: [PATCH 07/16] refactor: move provider wizard from plugin to built-in bin/ provider Moves the provider wizard out of the plugin system (.smallcode/plugins/provider-wizard/) into bin/provider-wizard/ as a built-in feature. This makes the wizard always available without requiring plugin install, and gives the PR its own clear identity. Changes: - bin/provider-wizard/ - wizard, status, tool-configure, tool-status (moved from plugin) - bin/commands.js - /provider command wired directly (not via plugin dispatch) - bin/executor.js - configure_provider + provider_status tool cases added - bin/tools.js - PROVIDER_TOOLS schema array (configure_provider, provider_status) - bin/smallcode.js - ALL_TOOLS includes PROVIDER_TOOLS - .smallcode/plugins/provider-wizard/ - removed (no longer a plugin) Co-Authored-By: Claude Opus 4.7 --- .smallcode/plugins/provider-wizard/cmd.js | 20 ------ .../plugins/provider-wizard/plugin.json | 62 ------------------- bin/commands.js | 22 ++++++- bin/executor.js | 30 +++++++++ .../plugins => bin}/provider-wizard/status.js | 0 .../provider-wizard/tool-configure.js | 0 .../provider-wizard/tool-status.js | 0 .../plugins => bin}/provider-wizard/wizard.js | 0 bin/smallcode.js | 4 +- bin/tools.js | 8 ++- 10 files changed, 60 insertions(+), 86 deletions(-) delete mode 100644 .smallcode/plugins/provider-wizard/cmd.js delete mode 100644 .smallcode/plugins/provider-wizard/plugin.json rename {.smallcode/plugins => bin}/provider-wizard/status.js (100%) rename {.smallcode/plugins => bin}/provider-wizard/tool-configure.js (100%) rename {.smallcode/plugins => bin}/provider-wizard/tool-status.js (100%) rename {.smallcode/plugins => bin}/provider-wizard/wizard.js (100%) diff --git a/.smallcode/plugins/provider-wizard/cmd.js b/.smallcode/plugins/provider-wizard/cmd.js deleted file mode 100644 index 3583f9a6..00000000 --- a/.smallcode/plugins/provider-wizard/cmd.js +++ /dev/null @@ -1,20 +0,0 @@ -// Provider Wizard — /provider slash command handler - -const { runWizard } = require('./wizard'); -const { getStatus, formatStatus } = require('./status'); - -module.exports = async function providerCmd(args) { - const arg = (args || '').trim().toLowerCase(); - - if (arg === 'status' || arg === '--status' || arg === '-s') { - const status = getStatus(); - return '\n \x1b[1;36mProvider Status\x1b[0m\n' + formatStatus(status) + '\n'; - } - - const result = await runWizard({ interactive: true }); - - if (result.success) { - return ''; // wizard already printed the summary - } - return ` \x1b[31mFailed:\x1b[0m ${result.error}`; -}; diff --git a/.smallcode/plugins/provider-wizard/plugin.json b/.smallcode/plugins/provider-wizard/plugin.json deleted file mode 100644 index dfa9442c..00000000 --- a/.smallcode/plugins/provider-wizard/plugin.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "provider-wizard", - "version": "0.1.0", - "description": "Interactive wizard to configure LLM providers, API keys, models, and escalation", - "commands": [ - { - "name": "provider", - "handler": "./cmd.js" - } - ], - "tools": [ - { - "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": "Base URL for the provider API" - }, - "model": { - "type": "string", - "description": "Model name to use" - }, - "apiKey": { - "type": "string", - "description": "API key for the provider" - }, - "escalationProvider": { - "type": "string", - "description": "Optional escalation/fallback provider" - }, - "escalationModel": { - "type": "string", - "description": "Model for the escalation provider" - } - } - }, - "handler": "./tool-configure.js" - }, - { - "name": "provider_status", - "description": "Show current provider configuration status: which providers are configured, API keys present, models, and escalation setup.", - "parameters": { - "type": "object", - "properties": {} - }, - "handler": "./tool-status.js" - } - ], - "prompts": [ - { - "inject": "always", - "content": "The user has a /provider command to configure LLM providers. Available providers: LM Studio, Ollama, OpenRouter, OpenAI, Anthropic, DeepSeek, and custom endpoints. Use the configure_provider tool to help set up providers programmatically, or suggest the user run /provider for the interactive wizard." - } - ] -} diff --git a/bin/commands.js b/bin/commands.js index 9b46afc8..140c4c29 100644 --- a/bin/commands.js +++ b/bin/commands.js @@ -768,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')}`); @@ -777,6 +778,25 @@ 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 — strip leading / for lookup const { PluginLoader } = require('../src/plugins/loader'); 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/.smallcode/plugins/provider-wizard/status.js b/bin/provider-wizard/status.js similarity index 100% rename from .smallcode/plugins/provider-wizard/status.js rename to bin/provider-wizard/status.js diff --git a/.smallcode/plugins/provider-wizard/tool-configure.js b/bin/provider-wizard/tool-configure.js similarity index 100% rename from .smallcode/plugins/provider-wizard/tool-configure.js rename to bin/provider-wizard/tool-configure.js diff --git a/.smallcode/plugins/provider-wizard/tool-status.js b/bin/provider-wizard/tool-status.js similarity index 100% rename from .smallcode/plugins/provider-wizard/tool-status.js rename to bin/provider-wizard/tool-status.js diff --git a/.smallcode/plugins/provider-wizard/wizard.js b/bin/provider-wizard/wizard.js similarity index 100% rename from .smallcode/plugins/provider-wizard/wizard.js rename to bin/provider-wizard/wizard.js diff --git a/bin/smallcode.js b/bin/smallcode.js index 6de8a739..abb03e4b 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -46,7 +46,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 +435,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; diff --git a/bin/tools.js b/bin/tools.js index e12f2d5b..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 From a7f9231b4a95c22ccd58df7cbdd426e92eb7dc8a Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 13:38:23 -0500 Subject: [PATCH 08/16] fix: allow /provider to work when no model is configured The model gate at startup was blocking all commands including /provider. Now /provider commands are dispatched before the model check so users can configure a provider even with no model set. Also changes the no-model path to always show the setup TUI instead of only when a plugin command was registered (provider is now built-in). Co-Authored-By: Claude Opus 4.7 --- bin/smallcode.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/bin/smallcode.js b/bin/smallcode.js index abb03e4b..90e5931e 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -2589,17 +2589,20 @@ async function main() { // Check model is configured if (!config.model.name) { - // If a provider plugin command is available, boot minimal TUI for setup - if (pluginLoader.commands['provider']) { - console.log('\n ⚡ SmallCode — no model configured.\n'); - console.log(' Type /provider to configure a model, or /provider status to check.\n'); - startMinimalTUI(); + // 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 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.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); + 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 From e1c4ffacdaea731350156e0e0db30eed415dea60 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 13:52:11 -0500 Subject: [PATCH 09/16] fix: require createCommandHandler in main() provider dispatch Co-Authored-By: Claude Opus 4.7 --- bin/smallcode.js | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/smallcode.js b/bin/smallcode.js index 90e5931e..fb1b8182 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -2594,6 +2594,7 @@ async function main() { 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); From 7542cd5e4768bff537c393ac48fa379c8b5da1f8 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 13:57:38 -0500 Subject: [PATCH 10/16] fix: custom provider endpoint should prompt for API key Only ollama and LM Studio are truly key-free. Custom endpoints often need an API key (e.g. vLLM with auth, proxied APIs). Changed custom provider keyEnv from null to 'SMALLCODE_API_KEY' so the wizard prompts for it. Also fixed validateApiKey to accept the actual base URL instead of always using the provider's defaultUrl (which is empty for custom). Co-Authored-By: Claude Opus 4.7 --- bin/provider-wizard/status.js | 2 +- bin/provider-wizard/wizard.js | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bin/provider-wizard/status.js b/bin/provider-wizard/status.js index 5646750c..9c60f3c9 100644 --- a/bin/provider-wizard/status.js +++ b/bin/provider-wizard/status.js @@ -11,7 +11,7 @@ const PROVIDERS = { 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: null }, + custom: { name: 'Custom endpoint', defaultUrl: '', keyEnv: 'SMALLCODE_API_KEY' }, }; function parseEnvFile(filePath) { diff --git a/bin/provider-wizard/wizard.js b/bin/provider-wizard/wizard.js index 0527e0ed..22eeeb9a 100644 --- a/bin/provider-wizard/wizard.js +++ b/bin/provider-wizard/wizard.js @@ -42,15 +42,17 @@ function maskKey(key) { return key.slice(0, 4) + '***' + key.slice(-4); } -async function validateApiKey(provider, apiKey) { +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 url = `${info.defaultUrl}/models`; - const res = await fetch(url, { + const res = await fetch(`${url}/models`, { headers: { 'Authorization': `Bearer ${apiKey}` }, signal: AbortSignal.timeout(10000), }); @@ -183,7 +185,7 @@ async function runWizard(options = {}) { // Step 4: Validate API key if (providerInfo.keyEnv && apiKey && isInteractive) { process.stdout.write(' Validating API key...'); - const result = await validateApiKey(provider, apiKey); + const result = await validateApiKey(provider, apiKey, baseUrl); if (result.valid) { console.log(` \x1b[32mvalid\x1b[0m`); } else { From 78ce0c0e78eaa1620d403153e5b5f7524956fadc Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 14:02:07 -0500 Subject: [PATCH 11/16] fix: write provider config to global env so it persists across projects The wizard previously only wrote to project .env, causing re-prompting when switching projects. Now writes to ~/.config/smallcode/.env which the startup loader already checks as a global fallback. Project .env still gets written too if it exists, so project-level overrides work. Co-Authored-By: Claude Opus 4.7 --- bin/provider-wizard/wizard.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/bin/provider-wizard/wizard.js b/bin/provider-wizard/wizard.js index 22eeeb9a..b865ec80 100644 --- a/bin/provider-wizard/wizard.js +++ b/bin/provider-wizard/wizard.js @@ -111,6 +111,8 @@ async function runWizard(options = {}) { 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); @@ -229,7 +231,8 @@ async function runWizard(options = {}) { } } - // Step 7: Write .env + // 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, @@ -239,8 +242,18 @@ async function runWizard(options = {}) { envVars[providerInfo.keyEnv] = apiKey; } - const merged = mergeEnvFile(envPath, envVars); - fs.writeFileSync(envPath, merged, 'utf-8'); + // 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) { From 254d91a12a1fe0bd315767629903a12e3c25a882 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 14:03:57 -0500 Subject: [PATCH 12/16] fix: load all .env files instead of stopping at first found The loader used `break` after finding the first .env, so if a project had its own .env, the global ~/.config/smallcode/.env was never loaded. This caused provider config to disappear when switching projects. Now loads all env files with project-level values taking priority. Co-Authored-By: Claude Opus 4.7 --- bin/smallcode.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/smallcode.js b/bin/smallcode.js index fb1b8182..8e24bbe2 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 {} } })(); From 5dc0678d8e37daa799e2e144fc9333f7ed47facb Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 14:05:56 -0500 Subject: [PATCH 13/16] fix: add missing os import in wizard.js that crashed /provider command The os module was never imported, causing 'os is not defined' fatal error when the wizard tried to write to ~/.config/smallcode/. This meant the global env file was never created and provider config couldn't persist across projects. Co-Authored-By: Claude Opus 4.7 --- bin/provider-wizard/wizard.js | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/provider-wizard/wizard.js b/bin/provider-wizard/wizard.js index b865ec80..723f449e 100644 --- a/bin/provider-wizard/wizard.js +++ b/bin/provider-wizard/wizard.js @@ -4,6 +4,7 @@ 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) { From cffddaeabea1ae342c845c2a1d0dda8b1f89de98 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 14:11:41 -0500 Subject: [PATCH 14/16] fix: add SMALLCODE_API_KEY to all auth header chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /provider wizard stores the API key as SMALLCODE_API_KEY but the HTTP auth code only checked OPENAI_API_KEY, ANTHROPIC_API_KEY, and DEEPSEEK_API_KEY — so custom endpoint keys were never sent, causing 401 Unauthorized on every request. Co-Authored-By: Claude Opus 4.7 --- bin/config.js | 4 ++-- bin/smallcode.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/config.js b/bin/config.js index fad02716..b4102eb9 100644 --- a/bin/config.js +++ b/bin/config.js @@ -104,7 +104,7 @@ async function checkEndpoint(config) { 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}`; } @@ -161,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/smallcode.js b/bin/smallcode.js index 8e24bbe2..dfc7fb1c 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -1910,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}`; } @@ -2145,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'; @@ -2263,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'; From 85a5740f2650cea141ab6f3e0718799cb96e3031 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 14:48:43 -0500 Subject: [PATCH 15/16] fix: handle /provider command when model IS configured The /provider command was only dispatched when no model was set. This adds a handler before the positional prompt branch so users can reconfigure their provider even after one is already set. Co-Authored-By: Claude Opus 4.7 --- bin/smallcode.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bin/smallcode.js b/bin/smallcode.js index dfc7fb1c..436fbade 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -2693,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); From 5f1e8d8f68c3e256561656b3620b3ce5721aacee Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 10:35:30 -0500 Subject: [PATCH 16/16] feat: example Anthropic provider plugin (PR 2) End-to-end demonstration plugin implementing all 7 features: - adapter.js: IModelProvider for Anthropic Messages API (tools, vision) - init.js: API key validation on startup - cleanup.js: shutdown hook - pre-request.js / post-request.js / on-error.js: lifecycle hooks - plugin.json: full manifest with permissions, providers, capabilities Co-Authored-By: Claude Opus 4.7 --- .../plugins/anthropic-provider/adapter.js | 151 ++++++++++++++++++ .../plugins/anthropic-provider/cleanup.js | 4 + .smallcode/plugins/anthropic-provider/init.js | 8 + .../plugins/anthropic-provider/on-error.js | 8 + .../plugins/anthropic-provider/plugin.json | 21 +++ .../anthropic-provider/post-request.js | 4 + .../plugins/anthropic-provider/pre-request.js | 4 + 7 files changed, 200 insertions(+) create mode 100644 .smallcode/plugins/anthropic-provider/adapter.js create mode 100644 .smallcode/plugins/anthropic-provider/cleanup.js create mode 100644 .smallcode/plugins/anthropic-provider/init.js create mode 100644 .smallcode/plugins/anthropic-provider/on-error.js create mode 100644 .smallcode/plugins/anthropic-provider/plugin.json create mode 100644 .smallcode/plugins/anthropic-provider/post-request.js create mode 100644 .smallcode/plugins/anthropic-provider/pre-request.js 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. +};