diff --git a/_primitives/_rust/kei-mcp/Cargo.toml b/_primitives/_rust/kei-mcp/Cargo.toml index 919937b..d9d753b 100644 --- a/_primitives/_rust/kei-mcp/Cargo.toml +++ b/_primitives/_rust/kei-mcp/Cargo.toml @@ -18,7 +18,9 @@ path = "src/lib.rs" [dependencies] serde = { 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 } kei-atom-discovery = { path = "../kei-atom-discovery" } kei-skills = { path = "../kei-skills" } diff --git a/_primitives/_rust/kei-mcp/src/handlers/tools.rs b/_primitives/_rust/kei-mcp/src/handlers/tools.rs index 011546d..583f14f 100644 --- a/_primitives/_rust/kei-mcp/src/handlers/tools.rs +++ b/_primitives/_rust/kei-mcp/src/handlers/tools.rs @@ -33,6 +33,10 @@ pub fn list(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse { .into_iter() .map(atom_to_tool_descriptor) .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| { a.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"), }; 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 { Ok(result) => ok(req.id, json!({ "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/.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/.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 { + 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. fn atom_to_tool_descriptor(meta: AtomMeta) -> Value { let description = first_paragraph(&meta.body); diff --git a/_primitives/_rust/kei-mcp/tests/tools_list.rs b/_primitives/_rust/kei-mcp/tests/tools_list.rs index a1706df..03d5512 100644 --- a/_primitives/_rust/kei-mcp/tests/tools_list.rs +++ b/_primitives/_rust/kei-mcp/tests/tools_list.rs @@ -68,7 +68,12 @@ async fn tools_list_returns_two_atoms_with_descriptors() { let resp = dispatch(req, &ctx).await; let result = resp.result.expect("should have result"); 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 assert_eq!(tools[0]["name"], "kei-sage::ask"); 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 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"); } diff --git a/bin/kei b/bin/kei index b587946..191ca81 100755 --- a/bin/kei +++ b/bin/kei @@ -10,13 +10,15 @@ # kei --status # status only, don't launch claude # kei message ... # inter-session mailbox (send/inbox/list) — see kei-message.sh # kei configure # re-pick stack profile + opt-in hook packs +# kei pick # interactive picker → set primary → launch it # kei agent "" # invoke agent, backend from DNA → primary # kei agent --on= "" # override backend # kei run-via "" # invoke agent on explicit backend # # backends: claude grok agy copilot kimi codex # # `kei run-via list` shows install status + agents # kei primary [] # get/set primary LLM provider (DNA fallback) -# kei [args...] # splash → claude args... (forwarded verbatim) +# kei --on= # one-shot launch of (does not change primary) +# kei [args...] # splash → exec primary CLI (default: claude) # # The splash shows: substrate version, agent count, last sleep run, # active sessions (kei-ping). Press any key to skip the dwell. @@ -53,28 +55,88 @@ case "${1:-}" in shift exec "$HOME/.claude/scripts/kei-agent-cli.sh" primary "$@" ;; + pick) + shift + exec "$HOME/.claude/scripts/kei-pick.sh" "$@" + ;; esac +# --- one-shot --on= override (does not write primary.toml) ------- +ONESHOT_BACKEND="" +for arg in "$@"; do + case "$arg" in + --on=*) ONESHOT_BACKEND="${arg#--on=}" ;; + esac +done + # --- args ---------------------------------------------------------------- SPLASH=1 STATUS_ONLY=0 PASSTHROUGH=() for arg in "$@"; do case "$arg" in + --on=*) ;; # already captured in ONESHOT_BACKEND; don't forward --no-splash) SPLASH=0 ;; --status) STATUS_ONLY=1; SPLASH=1 ;; *) PASSTHROUGH+=("$arg") ;; esac done -# --- locate claude on PATH ----------------------------------------------- -CLAUDE_BIN="$(command -v claude 2>/dev/null || true)" -if [ -z "$CLAUDE_BIN" ] && [ "$STATUS_ONLY" = "0" ]; then - echo "error: 'claude' not on PATH. Install Claude Code first:" >&2 - echo " curl -fsSL https://claude.ai/install.sh | sh" >&2 +# --- resolve primary backend --------------------------------------------- +# Order: --on= override → ~/.claude/config/primary.toml → claude. +resolve_primary() { + if [ -n "$ONESHOT_BACKEND" ]; then printf '%s\n' "$ONESHOT_BACKEND"; return; fi + 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 (set a different default)" >&2 exit 127 fi +# Legacy var name for splash code below. +CLAUDE_BIN="$PRIMARY_BIN" + # --- read state ---------------------------------------------------------- AGENTS_DIR="${HOME}/.claude/agents" SYNC_DIR="${HOME}/.claude/memory/sync-repo" @@ -155,8 +217,9 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█ ${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0} ${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0} -${C2} KeiSeiKit · substrate v0.38${C0} +${C2} KeiSeiKit · substrate v0.39${C0} ${C3} ─────────────────────────────────────${C0} + primary CLI : ${CV}${PRIMARY}${C0} profile : ${CV}${p}${C0} agents : ${CV}${ac}${C0} last sleep run : ${CV}${sl}${C0} diff --git a/docs/encyclopedia/multi-cli-agents.md b/docs/encyclopedia/multi-cli-agents.md index 508a249..a389721 100644 --- a/docs/encyclopedia/multi-cli-agents.md +++ b/docs/encyclopedia/multi-cli-agents.md @@ -108,6 +108,69 @@ strengths; the substrate is agnostic about which you pick. Pick by: - **Independent second opinion** — same agent, different model, see if 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: ` 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 `/_primitives/_rust/target/release/kei-mcp` (built via +`cargo build -p kei-mcp --release`). + ## Rule enforcement caveat (READ THIS) KeiSeiKit hooks (`numeric-claims-guard`, `citation-verify`, `no-github-push`, diff --git a/scripts/kei-pick.sh b/scripts/kei-pick.sh new file mode 100755 index 0000000..40ba82d --- /dev/null +++ b/scripts/kei-pick.sh @@ -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 <\`) 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"