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:
+414
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env bun
|
||||
import { render } from 'ink';
|
||||
import React from 'react';
|
||||
import type { LanguageModel, ModelMessage } from 'ai';
|
||||
import { resolveAgent, VARIANTS, isThinkingLevel, type AgentVariant } from './agents';
|
||||
import { configPath, loadConfig, missingKeyMessage, resolveModel, writeConfigFile, type Config } from './config';
|
||||
import type { FallbackEvent } from './fallback';
|
||||
import { readStdin, runHeadless } from './headless';
|
||||
import { INIT_PROMPT, loadInstructions } from './instructions';
|
||||
import { connectMcp } from './mcp';
|
||||
import { Memory, KIND_LABEL } from './memory';
|
||||
import { costOf } from './pricing';
|
||||
import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin';
|
||||
import { createHost } from './plugins';
|
||||
import { fetchModels, presetById } from './providers';
|
||||
import { Session } from './session';
|
||||
import { loadSkills } from './skills';
|
||||
import * as store from './store';
|
||||
import { createTaskTool } from './subagent';
|
||||
import { VERSION, versionLine } from './version';
|
||||
import { createAskBridge } from './ui/Ask';
|
||||
import { App, createApprovalBridge, createNoticeBus, createSubagentBus, type AppHooks } from './ui/App';
|
||||
|
||||
// SDK warnings go straight to stderr, which tears up the Ink render.
|
||||
(globalThis as { AI_SDK_LOG_WARNINGS?: boolean }).AI_SDK_LOG_WARNINGS = false;
|
||||
|
||||
const HELP = `shiro-neko ${VERSION} - agentic coding CLI
|
||||
|
||||
usage: shiro [options]
|
||||
shiro -p "prompt" headless, prints to stdout
|
||||
cat file | shiro -p prompt read from stdin
|
||||
|
||||
options:
|
||||
-p, --print [prompt] headless mode; requires --yolo for tool use
|
||||
--json with -p, emit one JSON event per line
|
||||
-c, --continue resume the newest session for this directory
|
||||
-r, --resume <id> resume a session by id or id prefix
|
||||
--agent <name> ${VARIANTS.map((v) => v.name).join(' | ')}
|
||||
--think <level> off | low | medium | high | max
|
||||
--provider <anthropic|openai> wire protocol to use (default anthropic)
|
||||
--model <id> model id
|
||||
--base-url <url> OpenAI/Anthropic-compatible endpoint
|
||||
--no-mcp skip MCP servers from the config file
|
||||
--no-subagent omit the task tool
|
||||
--no-instructions ignore AGENTS.md / CLAUDE.md
|
||||
--no-skills ignore builtin and project skills
|
||||
--no-plugins disable all plugins, including the guard
|
||||
--no-memory do not load or write project memory
|
||||
--yolo skip all tool approval prompts
|
||||
-v, --version
|
||||
-h, --help
|
||||
|
||||
first run: start shiro with no key and it opens provider setup, or use /provider anytime.
|
||||
|
||||
config: ${configPath()}
|
||||
{ "provider": "openai", "model": "gpt-5", "apiKey": "...",
|
||||
"agent": "default", "thinking": "medium", "plugins": ["guard", "time"],
|
||||
"mcpServers": { "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] } } }
|
||||
|
||||
env: SHIRO_PROVIDER SHIRO_MODEL SHIRO_BASE_URL SHIRO_API_KEY
|
||||
ANTHROPIC_API_KEY OPENAI_API_KEY
|
||||
|
||||
skills: builtin, plus ~/.shiro-neko/skills/*.md and .shiro/skills/*.md
|
||||
sessions: ${store.sessionsDir()}
|
||||
in-session: /help for the command list`;
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
function flag(...names: string[]): string | undefined {
|
||||
for (const n of names) {
|
||||
const i = argv.indexOf(n);
|
||||
if (i === -1) continue;
|
||||
const next = argv[i + 1];
|
||||
return next && !next.startsWith('-') ? next : '';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const has = (...names: string[]) => names.some((n) => argv.includes(n));
|
||||
|
||||
if (has('-h', '--help')) {
|
||||
console.log(HELP);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (has('-v', '--version')) {
|
||||
console.log(versionLine());
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const providerFlag = flag('--provider');
|
||||
const modelFlag = flag('--model');
|
||||
const baseUrlFlag = flag('--base-url');
|
||||
if (providerFlag) process.env['SHIRO_PROVIDER'] = providerFlag;
|
||||
if (modelFlag) process.env['SHIRO_MODEL'] = modelFlag;
|
||||
if (baseUrlFlag) process.env['SHIRO_BASE_URL'] = baseUrlFlag;
|
||||
|
||||
let cfg = await loadConfig();
|
||||
const yolo = has('--yolo');
|
||||
const headless = flag('-p', '--print') !== undefined;
|
||||
|
||||
// A missing key is fatal for a pipe, but interactively it just means "not set up yet".
|
||||
if (!cfg.apiKey && headless) {
|
||||
console.error(`shiro: ${missingKeyMessage(cfg.provider)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const needsProvider = !cfg.apiKey;
|
||||
const notices = createNoticeBus();
|
||||
const subagents = createSubagentBus();
|
||||
const askBridge = createAskBridge();
|
||||
|
||||
function reportFallback(e: FallbackEvent): void {
|
||||
const line = `endpoint fallback: ${e.from} rejected the request, retrying on ${e.to}\n ${e.reason}`;
|
||||
if (headless) process.stderr.write(`shiro: ${line}\n`);
|
||||
else notices.emit(line);
|
||||
}
|
||||
|
||||
let languageModel: LanguageModel | undefined;
|
||||
if (cfg.apiKey) {
|
||||
try {
|
||||
languageModel = resolveModel(cfg, reportFallback);
|
||||
} catch (e) {
|
||||
console.error(`shiro: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let restored: store.SessionRecord | undefined;
|
||||
const resumeArg = flag('-r', '--resume');
|
||||
if (resumeArg) {
|
||||
const id = await store.resolveId(resumeArg);
|
||||
restored = id ? await store.load(id) : undefined;
|
||||
if (!restored) {
|
||||
console.error(`shiro: no session matching "${resumeArg}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (has('-c', '--continue')) {
|
||||
restored = await store.latest(process.cwd());
|
||||
if (!restored) {
|
||||
console.error('shiro: no saved session for this directory');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const mcp = has('--no-mcp') || !cfg.mcpServers ? undefined : await connectMcp(cfg.mcpServers);
|
||||
const instructions = has('--no-instructions') ? [] : await loadInstructions();
|
||||
const skills = has('--no-skills') ? [] : await loadSkills();
|
||||
const promptHistory = await store.loadHistory();
|
||||
|
||||
let agentVariant: AgentVariant;
|
||||
try {
|
||||
agentVariant = resolveAgent(flag('--agent') || cfg.agent, flag('--think') || cfg.thinking);
|
||||
} catch (e) {
|
||||
console.error(`shiro: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enabledPlugins = has('--no-plugins') ? [] : (cfg.plugins ?? DEFAULT_ENABLED);
|
||||
const pluginErrors = enabledPlugins
|
||||
.filter((name) => !BUILTIN_PLUGINS.some((p) => p.name === name))
|
||||
.map((name) => ({ plugin: name, message: 'no such plugin' }));
|
||||
const plugins = createHost(
|
||||
BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)),
|
||||
pluginErrors,
|
||||
);
|
||||
|
||||
const memory = has('--no-memory') ? undefined : new Memory(process.cwd(), languageModel);
|
||||
if (memory) await memory.load();
|
||||
|
||||
/** Placeholder until /provider supplies a key; it never gets called because the UI gates input. */
|
||||
const unconfiguredModel: LanguageModel = {
|
||||
specificationVersion: 'v4',
|
||||
provider: 'unconfigured',
|
||||
modelId: 'unconfigured',
|
||||
supportedUrls: {},
|
||||
doGenerate: () => Promise.reject(new Error('no provider configured - run /provider')),
|
||||
doStream: () => Promise.reject(new Error('no provider configured - run /provider')),
|
||||
};
|
||||
|
||||
const record: store.SessionRecord = restored ?? {
|
||||
id: store.newId(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
provider: cfg.provider,
|
||||
model: cfg.model,
|
||||
title: 'untitled',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
messages: [],
|
||||
};
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function persist(messages: ModelMessage[]): Promise<void> {
|
||||
record.messages = messages;
|
||||
record.title = store.titleOf(messages);
|
||||
record.inputTokens = session.inputTokens;
|
||||
record.outputTokens = session.outputTokens;
|
||||
record.notebook = session.notebook.state();
|
||||
const cost = costOf(record.model, session.inputTokens, session.outputTokens);
|
||||
if (cost !== undefined) record.costUsd = cost;
|
||||
await store.save(record);
|
||||
}
|
||||
|
||||
const bridge = createApprovalBridge();
|
||||
const session = new Session({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
askApproval: bridge.ask,
|
||||
yolo,
|
||||
instructions,
|
||||
skills,
|
||||
plugins,
|
||||
agent: agentVariant,
|
||||
// Headless has no one to answer, so the tool is withheld rather than left to hang.
|
||||
...(headless ? {} : { ask: askBridge.ask }),
|
||||
...(memory ? { memory } : {}),
|
||||
...(record.notebook ? { notebook: record.notebook } : {}),
|
||||
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}),
|
||||
extraTools: {
|
||||
...(mcp?.tools ?? {}),
|
||||
...(has('--no-subagent')
|
||||
? {}
|
||||
: {
|
||||
task: createTaskTool({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
...(headless ? {} : { report: subagents.emit }),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
autoApprove: ['task'],
|
||||
messages: [...record.messages],
|
||||
onChange: (messages) => {
|
||||
// Debounced so a long tool loop does not hit the disk on every step.
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => void persist(messages), 400);
|
||||
},
|
||||
});
|
||||
|
||||
async function shutdown(code: number): Promise<never> {
|
||||
clearTimeout(saveTimer);
|
||||
if (session.messages.length > 0) await persist(session.messages);
|
||||
await mcp?.close();
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const printArg = flag('-p', '--print');
|
||||
if (printArg !== undefined) {
|
||||
const prompt = printArg || (await readStdin());
|
||||
if (!prompt) {
|
||||
console.error('shiro: -p needs a prompt argument or piped stdin');
|
||||
await shutdown(1);
|
||||
}
|
||||
if (!yolo) {
|
||||
process.stderr.write('shiro: headless denies write_file, edit_file, bash and mcp tools unless --yolo is passed\n');
|
||||
}
|
||||
const code = await runHeadless({ session, prompt, format: has('--json') ? 'json' : 'text' });
|
||||
await shutdown(code);
|
||||
}
|
||||
|
||||
function applyConfig(next: Config): void {
|
||||
cfg = next;
|
||||
record.provider = next.provider;
|
||||
record.model = next.model;
|
||||
session.setModel(resolveModel(next, reportFallback));
|
||||
}
|
||||
|
||||
const hooks: AppHooks = {
|
||||
sessionId: record.id,
|
||||
config: () => cfg,
|
||||
instructionFiles: () => instructions.map((i) => i.path),
|
||||
initPrompt: INIT_PROMPT,
|
||||
history: promptHistory,
|
||||
recordPrompt: (text) => void store.appendHistory(text),
|
||||
agentName: () => session.agent().name,
|
||||
thinkingLevel: () => session.agent().thinking,
|
||||
switchModel: (id) => {
|
||||
applyConfig({ ...cfg, model: id });
|
||||
return `model is now ${id}`;
|
||||
},
|
||||
switchAgent: (name) => {
|
||||
const next = resolveAgent(name, session.agent().thinking);
|
||||
session.setAgent(next);
|
||||
const scope = next.allowTools ? ` (read-only: ${next.allowTools.length} tools)` : '';
|
||||
return `agent is now ${next.name}, thinking ${next.thinking}${scope}`;
|
||||
},
|
||||
switchThinking: (level) => {
|
||||
if (!isThinkingLevel(level)) throw new Error(`Unknown thinking level "${level}"`);
|
||||
session.setAgent({ ...session.agent(), thinking: level });
|
||||
return `thinking is now ${level}`;
|
||||
},
|
||||
listSkills: () => {
|
||||
if (skills.length === 0) return 'no skills loaded';
|
||||
return skills.map((s) => `${s.name.padEnd(10)} ${s.origin.padEnd(8)} ${s.description}`).join('\n');
|
||||
},
|
||||
listPlugins: () => {
|
||||
const active = plugins.plugins.map((p) => `${p.name.padEnd(8)} ${p.description}`);
|
||||
const failed = plugins.errors.map((e) => `${e.plugin.padEnd(8)} ${e.message}`);
|
||||
if (active.length === 0 && failed.length === 0) return 'no plugins active';
|
||||
return [...active, ...failed].join('\n');
|
||||
},
|
||||
listMemory: async () => {
|
||||
if (!memory) return 'memory is disabled (--no-memory)';
|
||||
const all = await memory.load();
|
||||
if (all.length === 0) return 'nothing remembered about this project yet';
|
||||
return all
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((e) => `(${KIND_LABEL[e.kind]}) ${e.text}${e.hits > 0 ? ` [recalled ${e.hits}x]` : ''}`)
|
||||
.join('\n');
|
||||
},
|
||||
summarizeMemory: async () => {
|
||||
if (!memory) return 'memory is disabled (--no-memory)';
|
||||
const { before, after } = await memory.summarize();
|
||||
return before === after
|
||||
? `memory left as is: ${before} entries, too few unused ones to merge`
|
||||
: `memory compacted: ${before} entries into ${after}`;
|
||||
},
|
||||
applyProvider: async (result) => {
|
||||
const next: Config = {
|
||||
...cfg,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
baseURL: result.baseURL,
|
||||
apiKey: result.apiKey,
|
||||
presetId: result.presetId,
|
||||
};
|
||||
applyConfig(next);
|
||||
const path = await writeConfigFile({
|
||||
provider: next.provider,
|
||||
model: next.model,
|
||||
baseURL: next.baseURL,
|
||||
apiKey: next.apiKey,
|
||||
presetId: next.presetId,
|
||||
});
|
||||
const label = presetById(result.presetId)?.label ?? result.presetId;
|
||||
return `${label} configured with ${result.model}\nsaved to ${path}`;
|
||||
},
|
||||
listModels: async () => {
|
||||
if (!cfg.apiKey) return { models: [], warning: 'no API key set - run /provider' };
|
||||
const preset = presetById(cfg.presetId ?? cfg.provider);
|
||||
const { models, warning } = await fetchModels(
|
||||
{
|
||||
kind: cfg.provider,
|
||||
baseURL: cfg.baseURL ?? '',
|
||||
...(preset?.fallbackModels ? { fallbackModels: preset.fallbackModels } : {}),
|
||||
},
|
||||
cfg.apiKey,
|
||||
);
|
||||
return warning ? { models, warning } : { models };
|
||||
},
|
||||
listSessions: async () => {
|
||||
const all = await store.list(15);
|
||||
if (all.length === 0) return 'no saved sessions';
|
||||
return all
|
||||
.map(
|
||||
(r) =>
|
||||
`${r.id.slice(0, 8)} ${r.updatedAt.slice(0, 16).replace('T', ' ')} ${r.messages.length}msg ${r.title}`,
|
||||
)
|
||||
.join('\n');
|
||||
},
|
||||
resumeSession: async (idOrPrefix) => {
|
||||
const id = await store.resolveId(idOrPrefix);
|
||||
const rec = id ? await store.load(id) : undefined;
|
||||
if (!rec) throw new Error(`no session matching "${idOrPrefix}"`);
|
||||
session.replace(rec.messages);
|
||||
record.id = rec.id;
|
||||
record.title = rec.title;
|
||||
hooks.sessionId = rec.id;
|
||||
return `resumed ${rec.id.slice(0, 8)} (${rec.messages.length} messages): ${rec.title}`;
|
||||
},
|
||||
saveSession: async () => {
|
||||
await persist(session.messages);
|
||||
return `saved ${record.id}`;
|
||||
},
|
||||
};
|
||||
|
||||
const header = [
|
||||
needsProvider
|
||||
? `shiro-neko ${VERSION} no provider configured`
|
||||
: `shiro-neko ${VERSION} ${cfg.provider}/${record.model} session ${record.id.slice(0, 8)}`,
|
||||
`agent: ${agentVariant.name} thinking: ${agentVariant.thinking}`,
|
||||
`cwd: ${process.cwd()}`,
|
||||
restored ? `resumed ${record.messages.length} messages` : undefined,
|
||||
instructions.length > 0
|
||||
? `instructions: ${instructions.map((i) => i.path.split(/[\\/]/).at(-1)).join(', ')}`
|
||||
: 'no AGENTS.md found - /init writes one',
|
||||
skills.length > 0 ? `skills: ${skills.map((s) => s.name).join(', ')}` : undefined,
|
||||
plugins.plugins.length > 0 ? `plugins: ${plugins.plugins.map((p) => p.name).join(', ')}` : undefined,
|
||||
...plugins.errors.map((e) => `plugin ${e.plugin}: ${e.message}`),
|
||||
memory && memory.all().length > 0 ? `memory: ${memory.all().length} notes about this project` : undefined,
|
||||
mcp && Object.keys(mcp.tools).length > 0 ? `mcp: ${Object.keys(mcp.tools).length} tools` : undefined,
|
||||
...(mcp?.errors ?? []).map((e) => `mcp ${e.server} failed: ${e.message}`),
|
||||
yolo ? 'approvals: OFF (--yolo)' : 'approvals: on for write_file, edit_file, bash, mcp__*',
|
||||
'/help for commands',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const app = render(
|
||||
<App
|
||||
session={session}
|
||||
bridge={bridge}
|
||||
header={header}
|
||||
hooks={hooks}
|
||||
notices={notices}
|
||||
askBridge={askBridge}
|
||||
subagents={subagents}
|
||||
needsProvider={needsProvider}
|
||||
/>,
|
||||
);
|
||||
await app.waitUntilExit();
|
||||
await shutdown(0);
|
||||
Reference in New Issue
Block a user