diff --git a/docs/quickstart.md b/docs/quickstart.md index b0c0da31..60621b9c 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -43,6 +43,13 @@ installed. With `--no-prompts`, supply any required API key through the environment (see Step 2); the wizard does not collect one interactively. Redirected downloads print periodic progress lines and a final byte count. +The wizard suggests `SYSKNIFE_LLM_PROVIDER` when set, otherwise the first provider +with a nonblank API key in the displayed order (OpenAI first), otherwise keyless +Ollama. Interactive answers can override that suggestion; `--no-prompts` uses it. +This selects a configuration, without checking server or model availability. +For Ollama, start `ollama serve` and load the selected model with `ollama pull` +(the default model is `qwen3:8b`). + ### From source **Prerequisites:** Rust stable (`rustup update stable`), **a C compiler and diff --git a/packages/setup/index.js b/packages/setup/index.js index ab0a687f..4cbbf5de 100755 --- a/packages/setup/index.js +++ b/packages/setup/index.js @@ -271,7 +271,7 @@ function serverToToml(key, server) { // the "wizard offers what the engine supports" invariant is unit-testable. All // eight sysknife-brain providers are offered; the flow below is data-driven off // these maps, so no per-provider branching is needed. -const { PROVIDERS, MODEL_DEFAULTS, API_KEY_VARS } = require('./providers.js'); +const { PROVIDERS, MODEL_DEFAULTS, API_KEY_VARS, defaultProvider } = require('./providers.js'); const ARG_SET = new Set(process.argv.slice(2)); const WANT_CLAUDE = ARG_SET.has('--claude'); @@ -597,9 +597,10 @@ async function main() { // uses the same model, only the daemon socket differs. console.log(); - const providerList = PROVIDERS.map((p, i) => (i === 0 ? `${B}${p}${X}` : p)).join(' / '); + const suggestedProvider = defaultProvider(process.env); + const providerList = PROVIDERS.map(p => (p === suggestedProvider ? `${B}${p}${X}` : p)).join(' / '); console.log(` LLM providers: ${providerList}`); - let provider = await ask(rl, lineQueue, 'LLM provider', 'openai'); + let provider = await ask(rl, lineQueue, 'LLM provider', suggestedProvider); provider = provider.toLowerCase(); if (!PROVIDERS.includes(provider)) { @@ -629,6 +630,9 @@ async function main() { console.log(); const model = await ask(rl, lineQueue, 'Model name', MODEL_DEFAULTS[provider]); + if (provider === 'ollama') { + step(`For Ollama, start the server with ollama serve and load the model: ollama pull ${model}`); + } // ── 5. Integration selection ───────────────────────────────────────────── @@ -1092,6 +1096,11 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) { Run from the root of your project directory. \x1b[1mENVIRONMENT\x1b[0m + SYSKNIFE_LLM_PROVIDER + Provider suggestion (interactive answers can override it). + Otherwise use the first configured cloud key in displayed order, + or keyless Ollama. Server and model availability are not checked. + OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY / GROQ_API_KEY / DEEPSEEK_API_KEY / MISTRAL_API_KEY / XAI_API_KEY The provider's key var (Ollama needs none). If set in your shell diff --git a/packages/setup/providers.js b/packages/setup/providers.js index bef37657..2a34d06f 100644 --- a/packages/setup/providers.js +++ b/packages/setup/providers.js @@ -15,7 +15,7 @@ * pick it up automatically. */ -/** Providers the wizard prompts for. Index 0 (`openai`) is the default. */ +/** Providers in display order; this also breaks ties between configured keys. */ const PROVIDERS = [ 'openai', 'anthropic', @@ -58,4 +58,11 @@ const API_KEY_VARS = { xai: 'XAI_API_KEY', }; -module.exports = { PROVIDERS, MODEL_DEFAULTS, API_KEY_VARS }; +/** Choose a suggestion without contacting providers or exposing key values. */ +function defaultProvider(env) { + const explicit = env.SYSKNIFE_LLM_PROVIDER?.trim(); + if (explicit) return explicit.toLowerCase(); + return PROVIDERS.find(p => API_KEY_VARS[p] && env[API_KEY_VARS[p]]?.trim()) ?? 'ollama'; +} + +module.exports = { PROVIDERS, MODEL_DEFAULTS, API_KEY_VARS, defaultProvider }; diff --git a/packages/setup/tests/providers.test.mjs b/packages/setup/tests/providers.test.mjs index 8fa68711..1cf7773b 100644 --- a/packages/setup/tests/providers.test.mjs +++ b/packages/setup/tests/providers.test.mjs @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; import test from 'node:test'; const require = createRequire(import.meta.url); -const { PROVIDERS, MODEL_DEFAULTS, API_KEY_VARS } = require('../providers.js'); +const { PROVIDERS, MODEL_DEFAULTS, API_KEY_VARS, defaultProvider } = require('../providers.js'); // The wizard must offer every provider the engine (sysknife-brain) supports, so // a user never has to hand-edit config.toml just to pick groq/deepseek/mistral/xai. @@ -26,10 +26,37 @@ test('offers every provider the engine supports', () => { assert.equal(PROVIDERS.length, ENGINE_PROVIDERS.length, 'PROVIDERS has unexpected extras'); }); -test('openai stays the first/default provider', () => { +test('openai stays first in the displayed provider list', () => { assert.equal(PROVIDERS[0], 'openai'); }); +test('keyless and blank-key environments suggest Ollama', () => { + assert.equal(defaultProvider({}), 'ollama'); + const env = Object.fromEntries(Object.values(API_KEY_VARS).filter(Boolean).map(key => [key, ' \t '])); + assert.equal(defaultProvider(env), 'ollama'); +}); + +test('each configured cloud key suggests its provider', () => { + for (const [provider, key] of Object.entries(API_KEY_VARS)) { + if (key) assert.equal(defaultProvider({ [key]: 'synthetic-key' }), provider); + } +}); + +test('multiple configured keys retain the displayed OpenAI-first preference', () => { + assert.equal(defaultProvider({ OPENAI_API_KEY: 'synthetic-openai', ANTHROPIC_API_KEY: 'synthetic-anthropic' }), 'openai'); +}); + +test('explicit provider suggestions take precedence and normalize surrounding whitespace', () => { + assert.equal(defaultProvider({ SYSKNIFE_LLM_PROVIDER: ' OLLAMA\t', OPENAI_API_KEY: 'synthetic-openai' }), 'ollama'); + assert.equal(defaultProvider({ SYSKNIFE_LLM_PROVIDER: ' GEMINI ', ANTHROPIC_API_KEY: 'synthetic-anthropic' }), 'gemini'); +}); + +test('blank explicit providers fall back while unknown values remain available for validation', () => { + assert.equal(defaultProvider({ SYSKNIFE_LLM_PROVIDER: ' \t ' }), 'ollama'); + assert.equal(defaultProvider({ SYSKNIFE_LLM_PROVIDER: '', OPENAI_API_KEY: 'synthetic-openai' }), 'openai'); + assert.equal(defaultProvider({ SYSKNIFE_LLM_PROVIDER: 'unknown-fixture' }), 'unknown-fixture'); +}); + test('maps each new provider to its engine API-key env var', () => { assert.equal(API_KEY_VARS.groq, 'GROQ_API_KEY'); assert.equal(API_KEY_VARS.deepseek, 'DEEPSEEK_API_KEY'); diff --git a/packages/setup/tests/setup-contract.test.mjs b/packages/setup/tests/setup-contract.test.mjs index c0e1a430..4415e084 100644 --- a/packages/setup/tests/setup-contract.test.mjs +++ b/packages/setup/tests/setup-contract.test.mjs @@ -12,12 +12,18 @@ const setupDir = path.resolve(here, '..'); const source = fs.readFileSync(path.join(setupDir, 'index.js'), 'utf8'); const daemonInstaller = fs.readFileSync(path.join(setupDir, 'install-daemon.js'), 'utf8'); -function runWizard({ daemonMode = 'skip', daemonInstall, cwd: suppliedCwd, env = {} } = {}) { +function runWizard({ daemonMode = 'skip', daemonInstall, cwd: suppliedCwd, env = {}, noPrompts = true, input = '' } = {}) { const cwd = suppliedCwd ?? fs.mkdtempSync(path.join(os.tmpdir(), 'sysknife-setup-contract-')); const ownsCwd = suppliedCwd === undefined; const entry = path.join(setupDir, 'index.js'); - const setupArgs = ['--claude', '--no-prompts', '--no-binary', `--daemon-mode=${daemonMode}`]; - const childEnv = { ...process.env, HOME: cwd, XDG_RUNTIME_DIR: cwd, OPENAI_API_KEY: '', ...env }; + const setupArgs = ['--claude', '--no-binary', `--daemon-mode=${daemonMode}`]; + if (noPrompts) setupArgs.push('--no-prompts'); + const childEnv = { ...process.env, HOME: cwd, XDG_RUNTIME_DIR: cwd }; + for (const name of ['SYSKNIFE_LLM_PROVIDER', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', + 'GEMINI_API_KEY', 'GROQ_API_KEY', 'DEEPSEEK_API_KEY', 'MISTRAL_API_KEY', 'XAI_API_KEY']) { + delete childEnv[name]; + } + Object.assign(childEnv, env); const bootstrap = [ "if (typeof process.getuid !== 'function') process.getuid = () => 1000;", ]; @@ -41,7 +47,7 @@ function runWizard({ daemonMode = 'skip', daemonInstall, cwd: suppliedCwd, env = return spawnSync(process.execPath, ['-e', bootstrap.join(' ')], { cwd, encoding: 'utf8', - input: '', + input, timeout: 30_000, env: childEnv, }); @@ -265,6 +271,7 @@ test('an installed user service retains its start and status guidance', () => { test('unattended key setup explains the missing environment key without offering a prompt', () => { const result = runWizard({ + env: { SYSKNIFE_LLM_PROVIDER: 'openai' }, daemonInstall: { mode: 'skip', daemonInstalled: false, manualSteps: ['Start manually: fixture'] }, }); const output = `${result.stdout}${result.stderr}`; @@ -273,6 +280,68 @@ test('unattended key setup explains the missing environment key without offering assert.match(output, /export OPENAI_API_KEY=your-key-here/); }); +for (const { label, env, provider, model } of [ + { label: 'keyless environment', env: {}, provider: 'ollama', model: 'qwen3:8b' }, + { label: 'blank cloud keys', env: { OPENAI_API_KEY: ' ', ANTHROPIC_API_KEY: '\t' }, provider: 'ollama', model: 'qwen3:8b' }, + { label: 'OpenAI key', env: { OPENAI_API_KEY: 'synthetic-openai' }, provider: 'openai', model: 'gpt-4.1' }, + { label: 'Anthropic key', env: { ANTHROPIC_API_KEY: 'synthetic-anthropic' }, provider: 'anthropic', model: 'claude-sonnet-4-6' }, + { label: 'explicit Ollama over a cloud key', env: { SYSKNIFE_LLM_PROVIDER: 'OLLAMA', OPENAI_API_KEY: 'synthetic-openai' }, provider: 'ollama', model: 'qwen3:8b' }, + { label: 'explicit Gemini', env: { SYSKNIFE_LLM_PROVIDER: 'gemini', GEMINI_API_KEY: 'synthetic-gemini' }, provider: 'gemini', model: 'gemini-2.0-flash' }, +]) { + test(`unattended provider selection respects ${label}`, () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sysknife-setup-provider-')); + try { + const result = runWizard({ cwd, env, + daemonInstall: { mode: 'skip', daemonInstalled: false, manualSteps: ['Start manually: fixture'] }, + }); + const output = `${result.stdout}${result.stderr}`; + assert.equal(result.status, 3, output); + const configText = fs.readFileSync(path.join(cwd, '.mcp.json'), 'utf8'); + const config = JSON.parse(configText).mcpServers.sysknife.env; + assert.equal(config.SYSKNIFE_LLM_PROVIDER, provider); + assert.equal(config.SYSKNIFE_LLM_MODEL, model); + assert.ok(!configText.includes('synthetic-'), configText); + assert.ok(!output.includes('synthetic-'), output); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(path.join(cwd, '.mcp.json')).mode & 0o777, 0o600); + } + if (provider === 'ollama') { + assert.doesNotMatch(output, /Set your API key|export OPENAI_API_KEY/); + assert.match(output, /ollama pull qwen3:8b/); + assert.doesNotMatch(output, /Ollama (?:detected|is running)/); + } + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); +} + +test('an interactive provider answer overrides the environment suggestion', () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sysknife-setup-provider-answer-')); + try { + const result = runWizard({ cwd, noPrompts: false, input: 'openai\n\n\nn\n', + env: { SYSKNIFE_LLM_PROVIDER: 'ollama', OPENAI_API_KEY: 'synthetic-openai' }, + daemonInstall: { mode: 'skip', daemonInstalled: false, manualSteps: ['Start manually: fixture'] }, + }); + assert.equal(result.status, 3, `${result.stdout}${result.stderr}`); + assert.equal(JSON.parse(fs.readFileSync(path.join(cwd, '.mcp.json'), 'utf8')).mcpServers.sysknife.env.SYSKNIFE_LLM_PROVIDER, 'openai'); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test('an unknown explicit unattended provider fails before writing config', () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sysknife-setup-provider-invalid-')); + try { + const result = runWizard({ cwd, env: { SYSKNIFE_LLM_PROVIDER: 'unknown-fixture' } }); + assert.equal(result.status, 1, `${result.stdout}${result.stderr}`); + assert.match(result.stderr, /Unknown provider "unknown-fixture"/); + assert.equal(fs.existsSync(path.join(cwd, '.mcp.json')), false); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + test('unattended setup keeps a supplied environment key out of generated config and output', () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sysknife-setup-env-key-')); const fixtureKey = 'synthetic-key-for-wizard-test';