feat(orchestrator): kei pick + spawn_agent MCP tool — true multi-LLM shell
Some checks are pending
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / preflight (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / vps-smoke (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:frustration-matrix,kei-frustration-loop,kei-skill-importer,kei-projects-index,kei-projects-watcher,kei-gdrive-import,kei-leak-matrix,kei-skills,kei-gateway,kei-cron-scheduler,kei-export-trajectories,kei-backend-daytona,kei-d… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-compute-baremetal,kei-compute-vultr,kei-compute-linode,kei-compute-digitalocean,kei-svc-systemd,kei-llm-bridge-mlx name:hosted-sleep-compute]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-diff,kei-scheduler,kei-watch,kei-prune,kei-discover,kei-brain-view,kei-hibernate,kei-ledger-sign,kei-fork name:wave13-15]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-git-gitea,kei-git-forgejo,kei-git-gitlab,kei-git-bitbucket,kei-memory-sled,kei-memory-redis,kei-memory-postgres,kei-memory-sqlite,kei-auth-google,kei-auth-apple,kei-auth-magiclink,kei-auth-webauthn,kei-notify-slack,kei-n… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-ledger,kei-migrate,kei-changelog,kei-memory,kei-store,kei-conflict-scan,kei-refactor-engine,kei-graph-check,kei-shared,kei-dna-index,kei-pet name:core]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-machine-probe,kei-llm-ollama,kei-llm-llamacpp,kei-llm-mlx,kei-llm-router,kei-model name:llm-stack]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-router,kei-sage,kei-task,kei-chat-store,kei-crossdomain,kei-search-core,kei-content-store,kei-social-store,kei-curator,kei-auth,kei-artifact name:mcp-lbm]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:keisei,kei-forge,kei-runtime,kei-runtime-core,kei-atom-discovery,kei-agent-runtime,kei-capability,kei-provision,kei-entity-store,kei-pipe,kei-cache,kei-spawn,kei-replay name:atom-substrate]) (push) Blocked by required conditions

Closes the "Claude Code as single primary" gap. Now `kei` (no args) execs
whichever CLI is configured as primary, and ANY MCP-capable orchestrator
can spawn KeiSeiKit agents on any backend via the built-in spawn_agent tool.

## A — orchestrator picker

bin/kei now reads ~/.claude/config/primary.toml and execs that CLI instead
of hardcoding claude. New arms:
  kei pick               interactive menu → set primary → launch it
  kei --on=<backend>     one-shot launch of <backend> (no primary write)
  kei primary [<b>]      get/set primary
Splash shows `primary CLI: <backend>` so the orchestrator is visible.
Failure mode: if primary's CLI isn't on PATH, prints install hint + offers
`kei pick` recovery.

scripts/kei-pick.sh — Constructor Pattern picker (<140 LOC). Lists all 6
backends with install status (✓/✗), highlights current primary, writes
choice to primary.toml, execs the picked CLI. Honors stdin TTY gate
(RULE TTY-INTERACTIVITY-GATE — -t 0, not -t 1) for non-interactive safety.

## B — spawn_agent MCP tool

_primitives/_rust/kei-mcp/src/handlers/tools.rs gains a built-in
`spawn_agent` tool, exposed alongside discovered atoms:
  - inputSchema: { name: str, task: str, on?: backend-enum }
  - Calls kei-agent-cli.sh internally with same DNA resolution
  - 60s timeout, kill-on-drop
  - Honors KEI_AGENT_CLI env for testing

Smoke 2026-05-26 (MCP stdio JSON-RPC round-trip):
  spawn_agent(name=smoke-test, on=claude) → "SMOKE-OK"   
  spawn_agent(name=smoke-test, on=grok)   → "SMOKE-OK"   

Why it matters: Claude Code has a native Agent tool. Grok / Agy / Copilot /
Kimi don't have an equivalent native sub-agent surface — but they all speak
MCP. spawn_agent gives them KeiSeiKit's sub-agent capability when they're
the orchestrator. The chosen orchestrator no longer caps the sub-agent fleet.

## Other

_primitives/_rust/kei-mcp/Cargo.toml: tokio gains "io-std" feature (was
missing — main.rs uses tokio::io::stdin/stdout). This fixes a latent build
error unrelated to this PR (kei-mcp wasn't building cleanly before).

Tests: tools_list assertions updated for the +1 built-in tool (3 total
instead of 2 with atoms; 1 instead of 0 on empty root). All MCP tests pass.
Assembler 3/3 golden tests still pass (provider field is optional).
This commit is contained in:
KeiSei84 2026-05-26 16:48:23 +08:00
parent e4980f6ad7
commit 3fec43ea7e
6 changed files with 403 additions and 10 deletions

View file

@ -18,7 +18,9 @@ path = "src/lib.rs"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true } # v0.39: io-std added for tokio::io::stdin/stdout used by the MCP stdio
# transport in main.rs (workspace tokio doesn't enable io-std by default).
tokio = { workspace = true, features = ["io-std"] }
anyhow = { workspace = true } anyhow = { workspace = true }
kei-atom-discovery = { path = "../kei-atom-discovery" } kei-atom-discovery = { path = "../kei-atom-discovery" }
kei-skills = { path = "../kei-skills" } kei-skills = { path = "../kei-skills" }

View file

@ -33,6 +33,10 @@ pub fn list(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse {
.into_iter() .into_iter()
.map(atom_to_tool_descriptor) .map(atom_to_tool_descriptor)
.collect(); .collect();
// v0.39: built-in spawn_agent tool — exposed to all MCP clients so any
// CLI (grok / agy / copilot / kimi / claude) can spawn a KeiSeiKit agent
// as a sub-agent. Bypasses atom discovery (it's an internal handler).
tools.push(spawn_agent_descriptor());
tools.sort_by(|a, b| { tools.sort_by(|a, b| {
a.get("name").and_then(Value::as_str).unwrap_or("") a.get("name").and_then(Value::as_str).unwrap_or("")
.cmp(b.get("name").and_then(Value::as_str).unwrap_or("")) .cmp(b.get("name").and_then(Value::as_str).unwrap_or(""))
@ -50,6 +54,18 @@ pub async fn call(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse {
None => return err(req.id, INVALID_PARAMS, "missing tool name"), None => return err(req.id, INVALID_PARAMS, "missing tool name"),
}; };
let args = params.get("arguments").cloned().unwrap_or(json!({})); let args = params.get("arguments").cloned().unwrap_or(json!({}));
// v0.39: spawn_agent built-in — short-circuit before atom dispatch.
if name == "spawn_agent" {
return match invoke_spawn_agent(&args).await {
Ok(text) => ok(req.id, json!({
"content": [{ "type": "text", "text": text }],
"isError": false,
})),
Err(e) => err(req.id, INTERNAL_ERROR, e),
};
}
match invoke_atom(&ctx.atoms_root, &name, &args).await { match invoke_atom(&ctx.atoms_root, &name, &args).await {
Ok(result) => ok(req.id, json!({ Ok(result) => ok(req.id, json!({
"content": [{ "type": "text", "text": serde_json::to_string(&result).unwrap_or_default() }], "content": [{ "type": "text", "text": serde_json::to_string(&result).unwrap_or_default() }],
@ -59,6 +75,94 @@ pub async fn call(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse {
} }
} }
/// v0.39: built-in `spawn_agent` MCP tool descriptor.
/// Exposes KeiSeiKit's cross-CLI agent launcher (`kei-agent-cli.sh`) so any
/// MCP client can spawn an agent on any backend (claude / grok / agy /
/// copilot / kimi). Solves the "non-claude orchestrator can't natively spawn
/// sub-agents" gap — any CLI with MCP support gets the spawn capability.
fn spawn_agent_descriptor() -> Value {
json!({
"name": "spawn_agent",
"description": "Spawn a KeiSeiKit agent as a sub-agent through any configured LLM CLI backend. Reads ~/.claude/agents/<name>.md, composes with the task, and execs the chosen backend non-interactively. Backend resolution: explicit `on` arg → agent manifest's `provider` → ~/.claude/config/primary.toml → claude.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Agent name (looked up in ~/.claude/agents/<name>.md)"
},
"task": {
"type": "string",
"description": "The task / question to give the agent"
},
"on": {
"type": "string",
"description": "Optional explicit backend override (claude/grok/agy/copilot/kimi/codex). Default: DNA → primary → claude.",
"enum": ["claude", "grok", "agy", "antigravity", "copilot", "kimi", "codex"]
}
},
"required": ["name", "task"]
}
})
}
/// v0.39: handler for `tools/call name=spawn_agent`. Shells out to
/// `kei-agent-cli.sh` (located via $HOME/.claude/scripts/) and returns
/// the backend's stdout as the tool result.
async fn invoke_spawn_agent(args: &Value) -> Result<String, String> {
let name = args.get("name").and_then(Value::as_str)
.ok_or_else(|| "spawn_agent: missing 'name' argument".to_string())?;
let task = args.get("task").and_then(Value::as_str)
.ok_or_else(|| "spawn_agent: missing 'task' argument".to_string())?;
let on_opt = args.get("on").and_then(Value::as_str);
// Locate the launcher script. Honors KEI_AGENT_CLI override for testing.
let script = match std::env::var("KEI_AGENT_CLI") {
Ok(v) => PathBuf::from(v),
Err(_) => {
let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
PathBuf::from(home).join(".claude/scripts/kei-agent-cli.sh")
}
};
if !script.is_file() {
return Err(format!("kei-agent-cli.sh not found: {}", script.display()));
}
let mut cmd = Command::new(&script);
if let Some(on) = on_opt {
cmd.arg(format!("--on={on}"));
}
cmd.arg(name).arg(task);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let child = cmd.spawn()
.map_err(|e| format!("spawn {}: {e}", script.display()))?;
let fut = child.wait_with_output();
// Reuse the existing ATOM_TIMEOUT_SECS for the spawn_agent cap too —
// 60s should suffice for non-interactive prompts; longer tasks would
// need streaming, which the MCP tools-call contract doesn't support
// anyway. Hung agents are killed at the timeout.
match tokio::time::timeout(Duration::from_secs(ATOM_TIMEOUT_SECS), fut).await {
Ok(Ok(out)) => {
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(format!(
"spawn_agent backend exited {}: {stderr}",
out.status.code().unwrap_or(-1)
));
}
Ok(stdout)
}
Ok(Err(e)) => Err(format!("wait: {e}")),
Err(_) => Err("spawn_agent timeout".into()),
}
}
/// Convert one atom's metadata into the MCP tool-descriptor shape. /// Convert one atom's metadata into the MCP tool-descriptor shape.
fn atom_to_tool_descriptor(meta: AtomMeta) -> Value { fn atom_to_tool_descriptor(meta: AtomMeta) -> Value {
let description = first_paragraph(&meta.body); let description = first_paragraph(&meta.body);

View file

@ -68,7 +68,12 @@ async fn tools_list_returns_two_atoms_with_descriptors() {
let resp = dispatch(req, &ctx).await; let resp = dispatch(req, &ctx).await;
let result = resp.result.expect("should have result"); let result = resp.result.expect("should have result");
let tools = result["tools"].as_array().expect("tools array"); let tools = result["tools"].as_array().expect("tools array");
assert_eq!(tools.len(), 2); // v0.39: list also includes the built-in `spawn_agent` tool (atoms + 1).
assert_eq!(tools.len(), 3);
assert!(
tools.iter().any(|t| t["name"] == "spawn_agent"),
"spawn_agent built-in must be present"
);
// sorted alphabetically // sorted alphabetically
assert_eq!(tools[0]["name"], "kei-sage::ask"); assert_eq!(tools[0]["name"], "kei-sage::ask");
assert_eq!(tools[1]["name"], "kei-task::search"); assert_eq!(tools[1]["name"], "kei-task::search");
@ -91,5 +96,8 @@ async fn tools_list_handles_empty_root() {
}; };
let resp = dispatch(req, &ctx).await; let resp = dispatch(req, &ctx).await;
let result = resp.result.expect("should have result"); let result = resp.result.expect("should have result");
assert_eq!(result["tools"].as_array().unwrap().len(), 0); // v0.39: empty atoms root still surfaces the built-in `spawn_agent` tool.
let tools = result["tools"].as_array().unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0]["name"], "spawn_agent");
} }

77
bin/kei
View file

@ -10,13 +10,15 @@
# kei --status # status only, don't launch claude # kei --status # status only, don't launch claude
# kei message ... # inter-session mailbox (send/inbox/list) — see kei-message.sh # kei message ... # inter-session mailbox (send/inbox/list) — see kei-message.sh
# kei configure # re-pick stack profile + opt-in hook packs # kei configure # re-pick stack profile + opt-in hook packs
# kei pick # interactive picker → set primary → launch it
# kei agent <name> "<task>" # invoke agent, backend from DNA → primary # kei agent <name> "<task>" # invoke agent, backend from DNA → primary
# kei agent --on=<backend> <name> "<task>" # override backend # kei agent --on=<backend> <name> "<task>" # override backend
# kei run-via <backend> <name> "<task>" # invoke agent on explicit backend # kei run-via <backend> <name> "<task>" # invoke agent on explicit backend
# # backends: claude grok agy copilot kimi codex # # backends: claude grok agy copilot kimi codex
# # `kei run-via list` shows install status + agents # # `kei run-via list` shows install status + agents
# kei primary [<backend>] # get/set primary LLM provider (DNA fallback) # kei primary [<backend>] # get/set primary LLM provider (DNA fallback)
# kei [args...] # splash → claude args... (forwarded verbatim) # kei --on=<backend> # one-shot launch of <backend> (does not change primary)
# kei [args...] # splash → exec primary CLI (default: claude)
# #
# The splash shows: substrate version, agent count, last sleep run, # The splash shows: substrate version, agent count, last sleep run,
# active sessions (kei-ping). Press any key to skip the dwell. # active sessions (kei-ping). Press any key to skip the dwell.
@ -53,28 +55,88 @@ case "${1:-}" in
shift shift
exec "$HOME/.claude/scripts/kei-agent-cli.sh" primary "$@" exec "$HOME/.claude/scripts/kei-agent-cli.sh" primary "$@"
;; ;;
pick)
shift
exec "$HOME/.claude/scripts/kei-pick.sh" "$@"
;;
esac esac
# --- one-shot --on=<backend> override (does not write primary.toml) -------
ONESHOT_BACKEND=""
for arg in "$@"; do
case "$arg" in
--on=*) ONESHOT_BACKEND="${arg#--on=}" ;;
esac
done
# --- args ---------------------------------------------------------------- # --- args ----------------------------------------------------------------
SPLASH=1 SPLASH=1
STATUS_ONLY=0 STATUS_ONLY=0
PASSTHROUGH=() PASSTHROUGH=()
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--on=*) ;; # already captured in ONESHOT_BACKEND; don't forward
--no-splash) SPLASH=0 ;; --no-splash) SPLASH=0 ;;
--status) STATUS_ONLY=1; SPLASH=1 ;; --status) STATUS_ONLY=1; SPLASH=1 ;;
*) PASSTHROUGH+=("$arg") ;; *) PASSTHROUGH+=("$arg") ;;
esac esac
done done
# --- locate claude on PATH ----------------------------------------------- # --- resolve primary backend ---------------------------------------------
CLAUDE_BIN="$(command -v claude 2>/dev/null || true)" # Order: --on=<backend> override → ~/.claude/config/primary.toml → claude.
if [ -z "$CLAUDE_BIN" ] && [ "$STATUS_ONLY" = "0" ]; then resolve_primary() {
echo "error: 'claude' not on PATH. Install Claude Code first:" >&2 if [ -n "$ONESHOT_BACKEND" ]; then printf '%s\n' "$ONESHOT_BACKEND"; return; fi
echo " curl -fsSL https://claude.ai/install.sh | sh" >&2 if [ -n "${KEI_PRIMARY:-}" ]; then printf '%s\n' "$KEI_PRIMARY"; return; fi
local cfg="$HOME/.claude/config/primary.toml"
if [ -f "$cfg" ]; then
awk -F'=' '/^provider[[:space:]]*=/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
gsub(/^"|"$/, "", $2)
print $2; exit
}' "$cfg"
return
fi
printf 'claude\n'
}
# Map backend name → executable. Mirrors scripts/kei-agent-cli.sh::backend_bin.
backend_bin_for() {
case "$1" in
claude) echo "claude" ;;
grok) echo "grok" ;;
agy|antigravity) echo "agy" ;;
copilot) echo "copilot" ;;
kimi) echo "kimi" ;;
codex) echo "codex" ;;
*) return 1 ;;
esac
}
PRIMARY="$(resolve_primary)"
PRIMARY_CLI="$(backend_bin_for "$PRIMARY")" || {
echo "error: unknown primary backend: $PRIMARY" >&2
exit 2
}
PRIMARY_BIN="$(command -v "$PRIMARY_CLI" 2>/dev/null || true)"
if [ -z "$PRIMARY_BIN" ] && [ "$STATUS_ONLY" = "0" ]; then
echo "error: primary backend '$PRIMARY' → '$PRIMARY_CLI' not on PATH." >&2
case "$PRIMARY" in
claude) echo " install: curl -fsSL https://claude.ai/install.sh | sh" >&2 ;;
grok) echo " install: see https://x.ai/grok" >&2 ;;
copilot) echo " install: npm i -g @github/copilot" >&2 ;;
kimi) echo " install: uv tool install kimi-cli" >&2 ;;
agy) echo " install: see https://antigravity.dev" >&2 ;;
codex) echo " install: see https://github.com/openai/codex" >&2 ;;
esac
echo " or: kei pick (interactive picker to choose + set primary)" >&2
echo " or: kei primary <backend> (set a different default)" >&2
exit 127 exit 127
fi fi
# Legacy var name for splash code below.
CLAUDE_BIN="$PRIMARY_BIN"
# --- read state ---------------------------------------------------------- # --- read state ----------------------------------------------------------
AGENTS_DIR="${HOME}/.claude/agents" AGENTS_DIR="${HOME}/.claude/agents"
SYNC_DIR="${HOME}/.claude/memory/sync-repo" SYNC_DIR="${HOME}/.claude/memory/sync-repo"
@ -155,8 +217,9 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0} ${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0} ${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
${C2} KeiSeiKit · substrate v0.38${C0} ${C2} KeiSeiKit · substrate v0.39${C0}
${C3} ─────────────────────────────────────${C0} ${C3} ─────────────────────────────────────${C0}
primary CLI : ${CV}${PRIMARY}${C0}
profile : ${CV}${p}${C0} profile : ${CV}${p}${C0}
agents : ${CV}${ac}${C0} agents : ${CV}${ac}${C0}
last sleep run : ${CV}${sl}${C0} last sleep run : ${CV}${sl}${C0}

View file

@ -108,6 +108,69 @@ strengths; the substrate is agnostic about which you pick. Pick by:
- **Independent second opinion** — same agent, different model, see if - **Independent second opinion** — same agent, different model, see if
conclusions diverge. conclusions diverge.
## Orchestrator picker — `kei` no longer hardcodes claude
Without args, `kei` reads `~/.claude/config/primary.toml` and execs that CLI.
The picker lets you change it interactively:
```bash
kei pick # interactive menu → set primary → launch it
kei # splash → exec the configured primary
kei --on=grok # one-shot launch of grok (does NOT change primary)
kei primary grok # set default to grok (no launch)
kei primary # show current primary
```
The splash shows `primary CLI: <backend>` so you always know which orchestrator
will start. If the chosen primary isn't installed, `kei` prints the install
command and offers `kei pick` as recovery.
## Cross-CLI sub-agent spawn via MCP — `spawn_agent`
`kei-mcp` exposes a built-in `spawn_agent` MCP tool. Any CLI that connects
to it as an MCP client can invoke KeiSeiKit agents on any backend, no matter
what the orchestrator is:
```jsonrpc
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "spawn_agent",
"arguments": {
"name": "critic",
"task": "review src/auth.rs for race conditions",
"on": "grok"
}
}
}
```
Internally `spawn_agent` shells out to `kei-agent-cli.sh` with the same DNA
resolution as `kei agent`. The `on` argument is optional — without it, the
backend is picked from the agent's manifest, then `primary.toml`, then claude.
**Why this matters:** Claude Code has a native `Agent` tool for sub-agent
spawning. Grok / Antigravity / Copilot / Kimi do NOT have that surface
natively — but they all support MCP. With `spawn_agent` exposed via kei-mcp,
**every backend that speaks MCP gets KeiSeiKit's sub-agent capability**. So
when Grok is your orchestrator, it can still spawn `critic` on Claude (or
`code-implementer` on Antigravity, or anything else) — the orchestrator
choice no longer caps your sub-agent surface.
Wire kei-mcp into the orchestrator's MCP config (each CLI has its own):
| CLI | MCP config |
|---|---|
| claude | `~/.claude/settings.json` `mcpServers` block |
| grok | `~/.grok/config.json` (or check `grok --help`) |
| agy | `~/.antigravity/mcp.json` (check `agy plugin list`) |
| copilot | `~/.copilot/mcp.json` (check `copilot --help`) |
| kimi | `kimi mcp add` subcommand |
Point each at `<kit>/_primitives/_rust/target/release/kei-mcp` (built via
`cargo build -p kei-mcp --release`).
## Rule enforcement caveat (READ THIS) ## Rule enforcement caveat (READ THIS)
KeiSeiKit hooks (`numeric-claims-guard`, `citation-verify`, `no-github-push`, KeiSeiKit hooks (`numeric-claims-guard`, `citation-verify`, `no-github-push`,

153
scripts/kei-pick.sh Executable file
View file

@ -0,0 +1,153 @@
#!/usr/bin/env bash
# kei-pick — interactive orchestrator picker.
#
# Shows installed LLM CLIs, lets the user choose one, writes it to
# ~/.claude/config/primary.toml, then exec's it (so the shell becomes
# the picked orchestrator). Designed for `kei pick`.
#
# Non-interactive (no TTY): just shows status and exits 0.
set -eu
KEI_PRIMARY_CFG="${KEI_PRIMARY_CFG:-$HOME/.claude/config/primary.toml}"
# Mirrors scripts/kei-agent-cli.sh::backend_bin and bin/kei::backend_bin_for.
backend_bin() {
case "$1" in
claude) echo "claude" ;;
grok) echo "grok" ;;
agy|antigravity) echo "agy" ;;
copilot) echo "copilot" ;;
kimi) echo "kimi" ;;
codex) echo "codex" ;;
*) return 1 ;;
esac
}
backend_label() {
case "$1" in
claude) echo "Claude Code (Anthropic)" ;;
grok) echo "Grok Build TUI (xAI)" ;;
agy) echo "Antigravity / Gemini (Google)" ;;
copilot) echo "GitHub Copilot CLI (Microsoft/GitHub)" ;;
kimi) echo "Kimi Code CLI (Moonshot) — TUI-only for agents" ;;
codex) echo "Codex CLI (OpenAI)" ;;
esac
}
current_primary() {
[ -f "$KEI_PRIMARY_CFG" ] || { echo "claude"; return; }
awk -F'=' '/^provider[[:space:]]*=/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
gsub(/^"|"$/, "", $2)
print $2; exit
}' "$KEI_PRIMARY_CFG"
}
# --- list installed backends ------------------------------------------
BACKENDS=(claude grok agy copilot kimi codex)
INSTALLED=()
for b in "${BACKENDS[@]}"; do
bin=$(backend_bin "$b")
if command -v "$bin" >/dev/null 2>&1; then
INSTALLED+=("$b")
fi
done
cur=$(current_primary)
# --- non-interactive: just show status --------------------------------
# Gate on stdin (RULE TTY-INTERACTIVITY-GATE): -t 0, not -t 1.
# curl|bash tees stdout, so -t 1 false ≠ non-interactive.
if [ ! -t 0 ]; then
echo "kei pick — non-interactive mode"
echo "current primary: $cur"
echo "installed backends: ${INSTALLED[*]:-none}"
echo "(run \`kei pick\` from a real terminal for the picker)"
exit 0
fi
# --- interactive picker -----------------------------------------------
C0="" CB="" CC="" CD=""
if [ -t 1 ]; then
C0=$'\033[0m'
CB=$'\033[1;38;5;39m' # blue
CC=$'\033[1;38;5;220m' # gold
CD=$'\033[2m' # dim
fi
cat <<EOF
${CB}╔════════════════════════════════════════════╗
║ KeiSeiKit · orchestrator picker ║
╚════════════════════════════════════════════╝${C0}
Pick the LLM CLI that becomes your primary shell.
Any agent invocation (\`kei agent <name>\`) routes here unless DNA overrides.
EOF
i=1
for b in "${BACKENDS[@]}"; do
bin=$(backend_bin "$b")
label=$(backend_label "$b")
if command -v "$bin" >/dev/null 2>&1; then
mark="${CC}${C0}"
else
mark="${CD}${C0}"
label="$label ${CD}(not installed)${C0}"
fi
cur_mark=""
[ "$b" = "$cur" ] && cur_mark="${CC} ← current${C0}"
printf " ${CB}%d${C0}) %s %-10s %s%s\n" "$i" "$mark" "$b" "$label" "$cur_mark"
i=$((i+1))
done
echo
printf " ${CB}q${C0}) cancel (keep current: ${CC}%s${C0})\n\n" "$cur"
printf "Pick [1-${#BACKENDS[@]}/q]: "
read -r choice
choice="${choice:-q}"
case "$choice" in
q|Q|"") echo "cancelled."; exit 0 ;;
[1-9])
idx=$((choice-1))
if [ $idx -ge ${#BACKENDS[@]} ] || [ $idx -lt 0 ]; then
echo "invalid choice: $choice" >&2; exit 2
fi
new="${BACKENDS[$idx]}"
;;
*) echo "invalid choice: $choice" >&2; exit 2 ;;
esac
bin=$(backend_bin "$new")
if ! command -v "$bin" >/dev/null 2>&1; then
echo
echo "${CC}'$new' is not installed.${C0}"
echo "Set as primary anyway (you'll need to install it before \`kei\` will work)? [y/N]: "
read -r confirm
case "$confirm" in y|Y|yes) ;; *) echo "cancelled."; exit 0 ;; esac
fi
mkdir -p "$(dirname "$KEI_PRIMARY_CFG")"
printf '# kei primary — written %s\nprovider = "%s"\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$new" > "$KEI_PRIMARY_CFG"
echo
echo "${CC}${C0} primary set: $cur${CC}$new${C0}"
echo " config: $KEI_PRIMARY_CFG"
echo
if [ -n "${KEI_NO_LAUNCH:-}" ]; then
echo "(skipping launch — KEI_NO_LAUNCH set)"
exit 0
fi
if ! command -v "$bin" >/dev/null 2>&1; then
echo "${CD}skipping launch — $bin not on PATH; install it then run \`kei\`.${C0}"
exit 0
fi
echo "launching $new..."
exec "$bin"