Files
shiro-neko/test/cli.test.ts
T
Muhammad Zakir Ramadhan f8c3cc2d8e Ship the batch: undo, parallel subagents, lazy MCP, hot-reloaded skills; docs and CI/CD
Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates

Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
2026-09-17 17:49:19 +07:00

157 lines
5.7 KiB
TypeScript

import { afterAll, beforeAll, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { VERSION } from '../src/version';
/**
* The CLI entry point is an executable, not a module: importing it parses argv,
* loads config, connects MCP, and renders Ink. Nothing in `test/` imports it for
* that reason, and its flag surface was previously exercised only by hand.
*
* So it is tested the way a user meets it — by running the real entry point in a
* child process — with two things held constant. `SHIRO_HOME` points at a scratch
* directory, and every API-key variable is stripped, so the "no key" branches are
* deterministic rather than accidentally satisfied by the developer's shell. The
* entry is invoked by absolute path from a scratch cwd, so the repository's own
* `config.json`, `AGENTS.md`, and `.shiro/` entries stay out of the run.
*
* Only paths that exit before `render()` are covered; anything past it needs a
* TTY. That still reaches every argument-parsing decision, which is the point.
*/
const ROOT = join(import.meta.dir, '..');
const ENTRY = join(ROOT, 'src', 'cli.tsx');
/** Credential variables that would otherwise decide `cfg.apiKey` for us. */
const KEY_VARS = [
'SHIRO_API_KEY',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'SHIRO_PROVIDER',
'SHIRO_MODEL',
'SHIRO_BASE_URL',
];
let home: string;
let scratch: string;
beforeAll(() => {
home = mkdtempSync(join(tmpdir(), 'shiro-cli-home-'));
scratch = mkdtempSync(join(tmpdir(), 'shiro-cli-cwd-'));
});
afterAll(() => {
rmSync(home, { recursive: true, force: true });
rmSync(scratch, { recursive: true, force: true });
});
function cleanEnv(shiroHome: string): Record<string, string> {
// Bun.spawn's `env` replaces the environment, so start from a copy of ours and
// remove the credentials rather than hand-assembling a minimal one.
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
for (const k of KEY_VARS) delete env[k];
env['SHIRO_HOME'] = shiroHome;
return env;
}
async function cli(args: string[], shiroHome = home): Promise<{ stdout: string; stderr: string; code: number }> {
const proc = Bun.spawn([process.execPath, ENTRY, ...args], {
cwd: scratch,
env: cleanEnv(shiroHome),
// /dev/null, so `readStdin` sees EOF instead of blocking on a pipe nobody closes.
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
});
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout, stderr, code };
}
test('--help prints usage and exits 0', async () => {
const { stdout, code } = await cli(['--help']);
expect(code).toBe(0);
expect(stdout).toContain('usage: shiro');
expect(stdout).toContain('--yolo');
expect(stdout).toContain('--print');
expect(stdout).toContain('--agent');
});
test('-h is the same as --help', async () => {
const { stdout, code } = await cli(['-h']);
expect(code).toBe(0);
expect(stdout).toContain('usage: shiro');
});
test('--version prints the version line and exits 0', async () => {
const { stdout, code } = await cli(['--version']);
expect(code).toBe(0);
expect(stdout).toContain(`shiro-neko ${VERSION}`);
expect(stdout).toContain(process.platform);
});
test('-v is the same as --version', async () => {
const { stdout, code } = await cli(['-v']);
expect(code).toBe(0);
expect(stdout).toContain(`shiro-neko ${VERSION}`);
});
test('an unknown --agent fails with the valid list', async () => {
const { stderr, code } = await cli(['--agent', 'turbo']);
expect(code).toBe(1);
expect(stderr).toContain('Unknown agent "turbo"');
expect(stderr).toContain('default, quick, deep, plan, review');
});
test('an unknown --think fails with the valid list', async () => {
const { stderr, code } = await cli(['--think', 'turbo']);
expect(code).toBe(1);
expect(stderr).toContain('Unknown thinking level "turbo"');
expect(stderr).toContain('off, low, medium, high, max');
});
test('-p without a key refuses to run headless', async () => {
const { stderr, code } = await cli(['-p', 'hello']);
expect(code).toBe(1);
expect(stderr).toContain('No API key for provider "anthropic"');
});
test('--resume with no matching session fails', async () => {
const { stderr, code } = await cli(['-r', 'nosuchid']);
expect(code).toBe(1);
expect(stderr).toContain('no session matching "nosuchid"');
});
test('--continue with no saved session fails', async () => {
const { stderr, code } = await cli(['-c']);
expect(code).toBe(1);
expect(stderr).toContain('no saved session for this directory');
});
test('a configured key with no prompt and no stdin reports the missing prompt', async () => {
// A second home, so the key file this writes does not leak into the tests above.
const keyed = mkdtempSync(join(tmpdir(), 'shiro-cli-keyed-'));
try {
mkdirSync(join(keyed, '.shiro-neko'), { recursive: true });
await Bun.write(
join(keyed, '.shiro-neko', 'config.json'),
`${JSON.stringify({ provider: 'anthropic', model: 'claude-sonnet-4-5', apiKey: 'sk-test' }, null, 2)}\n`,
);
// The full isolation flag set is passed so this also proves they all parse and
// the module boots with them; with an empty stdin, `-p` must report the prompt.
const { stderr, code } = await cli(
['-p', '--no-plugins', '--no-skills', '--no-memory', '--no-instructions', '--no-mcp', '--no-subagent'],
keyed,
);
expect(code).toBe(1);
expect(stderr).toContain('needs a prompt');
} finally {
rmSync(keyed, { recursive: true, force: true });
}
});