Initial commit: shiro-neko 0.1.0-beta.1
Agentic coding CLI on Bun, Ink, and the AI SDK. Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff. Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming. Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model. Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents. State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies. Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems. 404 tests, typecheck clean.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadConfig, readConfigFile, writeConfigFile } from '../src/config';
|
||||
import { fetchModels, presetById, PRESETS } from '../src/providers';
|
||||
|
||||
let home: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
const ENV_KEYS = ['SHIRO_HOME', 'SHIRO_PROVIDER', 'SHIRO_MODEL', 'SHIRO_BASE_URL', 'SHIRO_API_KEY', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY'];
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
home = mkdtempSync(join(tmpdir(), 'shiro-cfg-'));
|
||||
process.env['SHIRO_HOME'] = home;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('every preset has a usable kind and a fetchable-looking base URL', () => {
|
||||
for (const p of PRESETS) {
|
||||
expect(['anthropic', 'openai']).toContain(p.kind);
|
||||
if (p.baseURL) expect(p.baseURL.startsWith('http')).toBe(true);
|
||||
expect(presetById(p.id)?.label).toBe(p.label);
|
||||
}
|
||||
});
|
||||
|
||||
test('writeConfigFile persists what onboarding chose and loadConfig reads it back', async () => {
|
||||
await writeConfigFile({
|
||||
provider: 'openai',
|
||||
model: 'llama-3.3-70b',
|
||||
baseURL: 'https://api.groq.com/openai/v1',
|
||||
apiKey: 'gsk_test',
|
||||
presetId: 'groq',
|
||||
});
|
||||
|
||||
const cfg = await loadConfig();
|
||||
expect(cfg.provider).toBe('openai');
|
||||
expect(cfg.model).toBe('llama-3.3-70b');
|
||||
expect(cfg.baseURL).toBe('https://api.groq.com/openai/v1');
|
||||
expect(cfg.apiKey).toBe('gsk_test');
|
||||
expect(cfg.presetId).toBe('groq');
|
||||
});
|
||||
|
||||
test('writeConfigFile merges instead of clobbering unrelated keys', async () => {
|
||||
await writeConfigFile({ mcpServers: { fs: { command: 'npx', args: ['-y', 'server'] } } });
|
||||
await writeConfigFile({ provider: 'openai', model: 'gpt-5', apiKey: 'sk-1' });
|
||||
|
||||
const file = await readConfigFile();
|
||||
expect(file.mcpServers?.['fs']).toEqual({ command: 'npx', args: ['-y', 'server'] });
|
||||
expect(file.model).toBe('gpt-5');
|
||||
});
|
||||
|
||||
test('env still overrides the saved config', async () => {
|
||||
await writeConfigFile({ provider: 'openai', model: 'gpt-5', apiKey: 'sk-file' });
|
||||
process.env['SHIRO_MODEL'] = 'gpt-5-mini';
|
||||
process.env['SHIRO_API_KEY'] = 'sk-env';
|
||||
|
||||
const cfg = await loadConfig();
|
||||
expect(cfg.model).toBe('gpt-5-mini');
|
||||
expect(cfg.apiKey).toBe('sk-env');
|
||||
});
|
||||
|
||||
test('a fresh install reports no api key so the TUI can open onboarding', async () => {
|
||||
expect((await loadConfig()).apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test('fetchModels reads data[].id from an OpenAI-shaped response and sends a bearer token', async () => {
|
||||
const captured: Record<string, string | null> = {};
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: (req) => {
|
||||
captured['auth'] = req.headers.get('authorization');
|
||||
return Response.json({ data: [{ id: 'gpt-5' }, { id: 'gpt-4o' }] });
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetchModels({ kind: 'openai', baseURL: `http://127.0.0.1:${server.port}/v1` }, 'sk-abc');
|
||||
server.stop(true);
|
||||
|
||||
expect(res.source).toBe('api');
|
||||
expect(res.models).toEqual(['gpt-4o', 'gpt-5']);
|
||||
expect(captured['auth']).toBe('Bearer sk-abc');
|
||||
});
|
||||
|
||||
test('fetchModels uses anthropic auth headers for the anthropic wire format', async () => {
|
||||
const h: Record<string, string | null> = {};
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: (req) => {
|
||||
h['key'] = req.headers.get('x-api-key');
|
||||
h['version'] = req.headers.get('anthropic-version');
|
||||
return Response.json({ data: [{ id: 'claude-sonnet-4-5' }] });
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetchModels({ kind: 'anthropic', baseURL: `http://127.0.0.1:${server.port}/v1` }, 'sk-ant-1');
|
||||
server.stop(true);
|
||||
|
||||
expect(res.models).toEqual(['claude-sonnet-4-5']);
|
||||
expect(h['key']).toBe('sk-ant-1');
|
||||
expect(h['version']).toBe('2023-06-01');
|
||||
});
|
||||
|
||||
test('a rejected key falls back to the preset list and reports why', async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: () => new Response('invalid api key', { status: 401 }),
|
||||
});
|
||||
|
||||
const res = await fetchModels(
|
||||
{ kind: 'openai', baseURL: `http://127.0.0.1:${server.port}/v1`, fallbackModels: ['gpt-5'] },
|
||||
'sk-bad',
|
||||
);
|
||||
server.stop(true);
|
||||
|
||||
expect(res.source).toBe('fallback');
|
||||
expect(res.models).toEqual(['gpt-5']);
|
||||
expect(res.warning).toContain('401');
|
||||
expect(res.warning).toContain('invalid api key');
|
||||
});
|
||||
|
||||
test('an unreachable endpoint degrades instead of throwing', async () => {
|
||||
const res = await fetchModels({ kind: 'openai', baseURL: 'http://127.0.0.1:1/v1' }, 'sk-x', 2000);
|
||||
expect(res.source).toBe('fallback');
|
||||
expect(res.models).toEqual([]);
|
||||
expect(res.warning).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a server that lists nothing is treated as a failure to list', async () => {
|
||||
const server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => Response.json({ data: [] }) });
|
||||
const res = await fetchModels({ kind: 'openai', baseURL: `http://127.0.0.1:${server.port}/v1` }, 'sk-x');
|
||||
server.stop(true);
|
||||
expect(res.source).toBe('fallback');
|
||||
expect(res.warning).toContain('listed no models');
|
||||
});
|
||||
Reference in New Issue
Block a user