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,63 @@
|
||||
# Installs shiro-neko from a GitHub release.
|
||||
#
|
||||
# irm https://raw.githubusercontent.com/zakirkun/shiro-neko/main/scripts/install.ps1 | iex
|
||||
#
|
||||
# Set $env:SHIRO_VERSION to pin a version, $env:SHIRO_INSTALL_DIR to change the target.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repo = if ($env:SHIRO_REPO) { $env:SHIRO_REPO } else { 'zakirkun/shiro-neko' }
|
||||
$installDir = if ($env:SHIRO_INSTALL_DIR) { $env:SHIRO_INSTALL_DIR } else { Join-Path $HOME '.bun\bin' }
|
||||
|
||||
if ([Environment]::Is64BitOperatingSystem -ne $true) {
|
||||
Write-Error 'shiro: only 64-bit Windows is supported'
|
||||
}
|
||||
$asset = 'shiro-windows-x64.exe'
|
||||
|
||||
$base = if ($env:SHIRO_VERSION) {
|
||||
"https://github.com/$repo/releases/download/v$($env:SHIRO_VERSION -replace '^v','')"
|
||||
} else {
|
||||
"https://github.com/$repo/releases/latest/download"
|
||||
}
|
||||
|
||||
$tmp = Join-Path $env:TEMP "shiro-install-$(Get-Random)"
|
||||
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||
|
||||
try {
|
||||
Write-Host "downloading $asset from $base"
|
||||
$downloaded = Join-Path $tmp $asset
|
||||
Invoke-WebRequest -Uri "$base/$asset" -OutFile $downloaded -UseBasicParsing
|
||||
|
||||
# Verify against the published checksums when they are available; a corrupted
|
||||
# 90 MB download otherwise fails later as an unexplained crash.
|
||||
try {
|
||||
$sums = (Invoke-WebRequest -Uri "$base/SHA256SUMS" -UseBasicParsing).Content
|
||||
$line = ($sums -split "`n" | Where-Object { $_ -match "\s$([regex]::Escape($asset))$" } | Select-Object -First 1)
|
||||
if ($line) {
|
||||
$expected = ($line -split '\s+')[0]
|
||||
$actual = (Get-FileHash -Path $downloaded -Algorithm SHA256).Hash.ToLower()
|
||||
if ($actual -ne $expected.ToLower()) {
|
||||
Write-Error 'shiro: checksum mismatch, refusing to install'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host 'no checksums published for this release, skipping verification'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
|
||||
$target = Join-Path $installDir 'shiro.exe'
|
||||
Move-Item -Force -Path $downloaded -Destination $target
|
||||
|
||||
Write-Host "installed $target"
|
||||
& $target --version
|
||||
|
||||
$onPath = ($env:PATH -split ';' | Where-Object { $_ -and (Join-Path $_ '') -eq (Join-Path $installDir '') })
|
||||
if ($onPath) {
|
||||
Write-Host 'run: shiro'
|
||||
} else {
|
||||
Write-Host ''
|
||||
Write-Host "$installDir is not on PATH. Add it:"
|
||||
Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"`$env:PATH;$installDir`", 'User')"
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env sh
|
||||
# Installs shiro-neko from a GitHub release.
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/zakirkun/shiro-neko/main/scripts/install.sh | sh
|
||||
#
|
||||
# Set SHIRO_VERSION to pin a version, SHIRO_INSTALL_DIR to change the target.
|
||||
set -eu
|
||||
|
||||
REPO="${SHIRO_REPO:-zakirkun/shiro-neko}"
|
||||
INSTALL_DIR="${SHIRO_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux) os=linux ;;
|
||||
Darwin) os=darwin ;;
|
||||
*) echo "shiro: unsupported OS $(uname -s). Windows users: use scripts/install.ps1" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64) arch=x64 ;;
|
||||
arm64 | aarch64) arch=arm64 ;;
|
||||
*) echo "shiro: unsupported architecture $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
asset="shiro-${os}-${arch}"
|
||||
|
||||
if [ -n "${SHIRO_VERSION:-}" ]; then
|
||||
tag="v${SHIRO_VERSION#v}"
|
||||
base="https://github.com/${REPO}/releases/download/${tag}"
|
||||
else
|
||||
base="https://github.com/${REPO}/releases/latest/download"
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
echo "downloading ${asset} from ${base}"
|
||||
if ! curl -fSL --progress-bar "${base}/${asset}" -o "${tmp}/shiro"; then
|
||||
echo "shiro: download failed. No build for ${os}-${arch} at that version?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify against the published checksums when they are available; a corrupted
|
||||
# 90 MB download otherwise fails later as an unexplained crash.
|
||||
if curl -fsSL "${base}/SHA256SUMS" -o "${tmp}/SHA256SUMS" 2>/dev/null; then
|
||||
expected="$(grep " ${asset}\$" "${tmp}/SHA256SUMS" | cut -d' ' -f1)"
|
||||
if [ -n "$expected" ]; then
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual="$(sha256sum "${tmp}/shiro" | cut -d' ' -f1)"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
actual="$(shasum -a 256 "${tmp}/shiro" | cut -d' ' -f1)"
|
||||
else
|
||||
actual=""
|
||||
fi
|
||||
if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then
|
||||
echo "shiro: checksum mismatch, refusing to install" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
chmod +x "${tmp}/shiro"
|
||||
mv "${tmp}/shiro" "${INSTALL_DIR}/shiro"
|
||||
|
||||
echo "installed ${INSTALL_DIR}/shiro"
|
||||
"${INSTALL_DIR}/shiro" --version || true
|
||||
|
||||
case ":${PATH}:" in
|
||||
*":${INSTALL_DIR}:"*) echo "run: shiro" ;;
|
||||
*) echo ""; echo "${INSTALL_DIR} is not on PATH. Add it:"; echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" ;;
|
||||
esac
|
||||
@@ -0,0 +1,58 @@
|
||||
import { chmodSync, mkdirSync } from 'node:fs';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { VERSION } from '../src/version';
|
||||
|
||||
/**
|
||||
* Installs the compiled binary onto PATH.
|
||||
*
|
||||
* `bun link` is not usable here: it writes a shim that re-execs `bun`, so it fails
|
||||
* on any machine where bun is installed without `bun.exe` on PATH (an npm install,
|
||||
* for one). The compiled binary embeds its own runtime and has no such dependency.
|
||||
*/
|
||||
const isWindows = platform() === 'win32';
|
||||
const exe = isWindows ? 'shiro.exe' : 'shiro';
|
||||
const source = join(import.meta.dir, '..', 'dist', exe);
|
||||
|
||||
const target = (() => {
|
||||
const explicit = process.env['SHIRO_INSTALL_DIR'];
|
||||
if (explicit) return explicit;
|
||||
const bunBin = join(homedir(), '.bun', 'bin');
|
||||
return isWindows ? bunBin : join(homedir(), '.local', 'bin');
|
||||
})();
|
||||
|
||||
const built = Bun.file(source);
|
||||
if (!(await built.exists())) {
|
||||
console.error(`shiro: ${source} not found. Run "bun run build" first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(target, { recursive: true });
|
||||
const dest = join(target, exe);
|
||||
|
||||
try {
|
||||
await Bun.write(dest, built);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.error(`shiro: could not write ${dest}: ${message}`);
|
||||
if (message.includes('EBUSY') || message.includes('EACCES') || message.includes('EPERM')) {
|
||||
console.error('shiro: a running shiro may be holding the file. Close it and try again.');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!isWindows) chmodSync(dest, 0o755);
|
||||
|
||||
const onPath = (process.env['PATH'] ?? '').split(isWindows ? ';' : ':').some((p) => p && join(p) === join(target));
|
||||
|
||||
console.log(`installed shiro-neko ${VERSION} to ${dest} (${(built.size / 1024 / 1024) | 0} MB)`);
|
||||
if (onPath) {
|
||||
console.log('run: shiro');
|
||||
} else {
|
||||
console.log(`\n${target} is not on PATH. Add it:`);
|
||||
console.log(
|
||||
isWindows
|
||||
? ` [Environment]::SetEnvironmentVariable('PATH', "$env:PATH;${target}", 'User')`
|
||||
: ` export PATH="${target}:$PATH"`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { VERSION } from '../src/version';
|
||||
|
||||
export type Target = {
|
||||
/** Bun cross-compilation target. */
|
||||
target: string;
|
||||
/** Suffix in the artifact name, matching what the installers look for. */
|
||||
name: string;
|
||||
windows?: boolean;
|
||||
};
|
||||
|
||||
export const TARGETS: Target[] = [
|
||||
{ target: 'bun-linux-x64', name: 'linux-x64' },
|
||||
{ target: 'bun-linux-arm64', name: 'linux-arm64' },
|
||||
{ target: 'bun-darwin-x64', name: 'darwin-x64' },
|
||||
{ target: 'bun-darwin-arm64', name: 'darwin-arm64' },
|
||||
{ target: 'bun-windows-x64', name: 'windows-x64', windows: true },
|
||||
];
|
||||
|
||||
const OUT = 'dist/release';
|
||||
|
||||
/**
|
||||
* Builds one executable per platform.
|
||||
*
|
||||
* Bun cross-compiles from any host, so a single runner produces every artifact and
|
||||
* no build matrix is needed. The version is compiled into the binary from
|
||||
* src/version.ts; a release tag must agree with it or the build stops, because a
|
||||
* binary reporting the wrong version is worse than a failed release.
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const only = args.filter((a) => !a.startsWith('-'));
|
||||
const wanted = only.length > 0 ? TARGETS.filter((t) => only.includes(t.name)) : TARGETS;
|
||||
if (wanted.length === 0) {
|
||||
console.error(`no matching target. Available: ${TARGETS.map((t) => t.name).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pkg = (await Bun.file('package.json').json()) as { version?: string };
|
||||
if (pkg.version !== VERSION) {
|
||||
console.error(`version mismatch: package.json is ${pkg.version}, src/version.ts is ${VERSION}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tag = (process.env['GITHUB_REF_NAME'] ?? '').replace(/^v/, '');
|
||||
if (tag && tag !== VERSION) {
|
||||
console.error(`tag v${tag} does not match src/version.ts (${VERSION}). Bump the version or retag.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
rmSync(OUT, { recursive: true, force: true });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const built: { file: string; bytes: number }[] = [];
|
||||
|
||||
for (const t of wanted) {
|
||||
const base = `shiro-${t.name}`;
|
||||
const outfile = join(OUT, base);
|
||||
|
||||
const buildArgs = ['build', '--compile', '--minify', `--target=${t.target}`, 'src/cli.tsx', '--outfile', outfile];
|
||||
if (t.windows) {
|
||||
buildArgs.push(
|
||||
'--windows-title=shiro-neko',
|
||||
'--windows-description=Agentic coding CLI',
|
||||
`--windows-version=${VERSION.split('-')[0]}.0`,
|
||||
);
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(['bun', ...buildArgs], { stdout: 'inherit', stderr: 'inherit' });
|
||||
const code = await proc.exited;
|
||||
if (code !== 0) {
|
||||
console.error(`\nbuild failed for ${t.name} (exit ${code})`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const file = t.windows ? `${base}.exe` : base;
|
||||
const artifact = Bun.file(join(OUT, file));
|
||||
if (!(await artifact.exists())) {
|
||||
console.error(`\n${file} was not produced`);
|
||||
process.exit(1);
|
||||
}
|
||||
built.push({ file, bytes: artifact.size });
|
||||
}
|
||||
|
||||
// Checksums let an installer verify a download without a second request.
|
||||
const sums: string[] = [];
|
||||
for (const { file } of built) {
|
||||
const bytes = new Uint8Array(await Bun.file(join(OUT, file)).arrayBuffer());
|
||||
sums.push(`${new Bun.CryptoHasher('sha256').update(bytes).digest('hex')} ${file}`);
|
||||
}
|
||||
await Bun.write(join(OUT, 'SHA256SUMS'), `${sums.join('\n')}\n`);
|
||||
|
||||
console.log(`\nshiro-neko ${VERSION}`);
|
||||
for (const { file, bytes } of built) console.log(` ${file.padEnd(26)} ${Math.round(bytes / 1024 / 1024)} MB`);
|
||||
console.log(` ${'SHA256SUMS'.padEnd(26)} ${built.length} entries`);
|
||||
}
|
||||
|
||||
// Guarded so a test can import TARGETS without triggering a five-platform build.
|
||||
if (import.meta.main) await main();
|
||||
Reference in New Issue
Block a user