Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions packages/setup/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────

Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions packages/setup/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 };
31 changes: 29 additions & 2 deletions packages/setup/tests/providers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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');
Expand Down
77 changes: 73 additions & 4 deletions packages/setup/tests/setup-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;",
];
Expand All @@ -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,
});
Expand Down Expand Up @@ -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}`;
Expand All @@ -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';
Expand Down
Loading