Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
173310a
feat: ProviderRegistry + plugin provider wiring for chat/escalation
TheArchitectit May 21, 2026
a9c6b37
feat: add prompt-inject provider plugin
TheArchitectit May 21, 2026
0390809
feat: lifecycle hooks + 5 new hook events for plugin system
TheArchitectit May 21, 2026
edd3776
feat: plugin manifest permissions, MCP declarations, capabilities
TheArchitectit May 21, 2026
f27d914
fix: export PROVIDER_TOOLS from tools.js
TheArchitectit May 21, 2026
796fd84
feat: example Anthropic provider plugin (PR 2)
TheArchitectit May 21, 2026
c420999
feat: provider wizard plugin + plugin install scopes + symlink fix
TheArchitectit May 21, 2026
7baed16
refactor: move provider wizard from plugin to built-in bin/ provider
TheArchitectit May 21, 2026
e7469bc
fix: allow /provider to work when no model is configured
TheArchitectit May 21, 2026
29b01c1
fix: require createCommandHandler in main() provider dispatch
TheArchitectit May 21, 2026
83f61bb
fix: custom provider endpoint should prompt for API key
TheArchitectit May 21, 2026
aefe07a
fix: write provider config to global env so it persists across projects
TheArchitectit May 21, 2026
bbf97df
fix: load all .env files instead of stopping at first found
TheArchitectit May 21, 2026
7115320
fix: add missing os import in wizard.js that crashed /provider command
TheArchitectit May 21, 2026
b0f2c21
fix: add SMALLCODE_API_KEY to all auth header chains
TheArchitectit May 21, 2026
ce4a697
fix: handle /provider command when model IS configured
TheArchitectit May 21, 2026
6213128
fix: address all 8 review issues from Doorman11991
May 24, 2026
2cc6c82
fix: adapter.js use tc.function.name exclusively, fix second /provide…
May 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions .smallcode/plugins/anthropic-provider/adapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// 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.function.name,
input: JSON.parse(tc.function.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,
type: 'function',
function: {
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;
4 changes: 4 additions & 0 deletions .smallcode/plugins/anthropic-provider/cleanup.js
Original file line number Diff line number Diff line change
@@ -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
};
8 changes: 8 additions & 0 deletions .smallcode/plugins/anthropic-provider/init.js
Original file line number Diff line number Diff line change
@@ -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.`);
}
};
8 changes: 8 additions & 0 deletions .smallcode/plugins/anthropic-provider/on-error.js
Original file line number Diff line number Diff line change
@@ -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');
}
};
21 changes: 21 additions & 0 deletions .smallcode/plugins/anthropic-provider/plugin.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
4 changes: 4 additions & 0 deletions .smallcode/plugins/anthropic-provider/post-request.js
Original file line number Diff line number Diff line change
@@ -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.
};
4 changes: 4 additions & 0 deletions .smallcode/plugins/anthropic-provider/pre-request.js
Original file line number Diff line number Diff line change
@@ -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.
};
12 changes: 12 additions & 0 deletions .smallcode/plugins/prompt-inject/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
99 changes: 71 additions & 28 deletions bin/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <npm-package-or-github-url>'));
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 <pkg> [--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 <name>'));
console.log(chalk.gray(' Usage: /plugin remove <name> [--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 <pkg> Install from npm/github'));
console.log(chalk.gray(' /plugin remove <name> Remove a plugin'));
console.log(chalk.gray(' /plugin list Show installed plugins'));
console.log(chalk.gray(' /plugin install <pkg> [--scope ...] Install (default: project)'));
console.log(chalk.gray(' /plugin remove <name> [--scope ...] Remove a plugin'));
console.log(chalk.gray(' Scopes: project (./.smallcode), user (~/.smallcode), global (~/.config/smallcode)'));
}
console.log('');
rl.prompt();
Expand Down Expand Up @@ -744,6 +768,7 @@ 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('/provider')} ${chalk.gray('Configure LLM provider (interactive wizard)')}`);
console.log(` ${chalk.cyan('/sessions')} ${chalk.gray('List/resume saved sessions')}`);
console.log(` ${chalk.cyan('/trace')} ${chalk.gray('View/export execution traces')}`);
console.log(` ${chalk.cyan('/eval')} <suite> ${chalk.gray('Run prompt evaluation')}`);
Expand All @@ -753,12 +778,30 @@ 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 result = await pWizard.runWizard({ interactive: true });
if (result.success) {
console.log(result.provider || '');
}
}
console.log('');
rl.prompt();
return;
}

default: {
// Try plugin commands before showing "unknown"
// Try plugin commands — strip leading / for lookup
const { PluginLoader } = require('../src/plugins/loader');
const pl = new PluginLoader(process.cwd()).loadAll();
if (pl.commands[parts[0]]) {
const result = await pl.executeCommand(parts[0], parts.slice(1).join(' '), { config, conversationHistory });
const cmdName = parts[0].replace(/^\//, '');
if (pl.commands[cmdName]) {
const result = await pl.executeCommand(cmdName, parts.slice(1).join(' '), { config, conversationHistory });
if (result) console.log(result);
} else {
console.log(chalk.gray(` Unknown: ${parts[0]}. Type /help`));
Expand Down
11 changes: 9 additions & 2 deletions bin/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,18 @@ function loadConfig(flags = {}) {
async function checkEndpoint(config) {
const baseUrl = config.model.baseUrl || process.env.OLLAMA_HOST || 'http://localhost:11434';

// Plugin-registered providers handle their own connectivity
const { providerRegistry } = require('../src/compiled/providers/registry');
if (providerRegistry.has(config.model.provider)) {
console.log(` Using plugin provider: ${config.model.provider}`);
return true;
}

// OpenAI-compatible endpoint (LM Studio, vLLM, OpenRouter, etc.)
if (config.model.provider === 'openai' || baseUrl.includes('/v1')) {
try {
const headers = {};
const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey;
const apiKey = process.env.SMALLCODE_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey;
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
Expand Down Expand Up @@ -154,7 +161,7 @@ async function checkEndpoint(config) {
*/
function buildAuthHeaders(config) {
const headers = { 'Content-Type': 'application/json' };
const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey;
const apiKey = process.env.SMALLCODE_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.DEEPSEEK_API_KEY || config.model.apiKey;
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
Expand Down
Loading