Compare commits
No commits in common. "main" and "phase2-optin" have entirely different histories.
main
...
phase2-opt
35 changed files with 80 additions and 2976 deletions
2
.gitmodules
vendored
2
.gitmodules
vendored
|
|
@ -1,4 +1,4 @@
|
|||
[submodule "_blocks/registries"]
|
||||
path = _blocks/registries
|
||||
url = https://github.com/KeiSeiLab/kei-registries.git
|
||||
url = https://keigit.com/keisei/kei-registries.git
|
||||
shallow = true
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ curl -fsSL https://install.keisei.app | bash -s -- --profile=dev --yes # CI
|
|||
/plugin install keisei@keisei-marketplace
|
||||
|
||||
# Any MCP-compatible client (Cursor / Continue / Zed / Aider / etc)
|
||||
git clone https://github.com/KeiSeiLab/KeiSeiKit-1.0.git
|
||||
git clone https://keigit.com/keisei/KeiSeiKit-1.0.git
|
||||
cd KeiSeiKit-1.0
|
||||
./bootstrap.sh # interactive profile picker
|
||||
# or: ./install.sh --profile=minimal # direct
|
||||
|
|
|
|||
|
|
@ -45,12 +45,6 @@ fn write_frontmatter(m: &Manifest, out: &mut String) {
|
|||
out.push_str(&format!("description: {}\n", desc.trim()));
|
||||
out.push_str(&format!("tools: {}\n", m.tools.join(", ")));
|
||||
out.push_str(&format!("model: {}\n", m.model));
|
||||
// v0.39: optional provider for DNA-resolved kei agent dispatch.
|
||||
if let Some(prov) = &m.provider {
|
||||
if !prov.is_empty() {
|
||||
out.push_str(&format!("provider: {}\n", prov));
|
||||
}
|
||||
}
|
||||
out.push_str("---\n\n");
|
||||
out.push_str(&format!(
|
||||
"<!-- GENERATED by _assembler (Rust) from _manifests/{}.toml — DO NOT EDIT. Edit the manifest. -->\n\n",
|
||||
|
|
|
|||
|
|
@ -9,13 +9,6 @@ pub struct Manifest {
|
|||
pub description: String,
|
||||
pub tools: Vec<String>,
|
||||
pub model: String,
|
||||
/// v0.39 (multi-CLI): optional LLM provider this agent prefers when invoked
|
||||
/// via `kei agent <name>`. Values: claude / grok / agy / copilot / kimi /
|
||||
/// codex. Empty / missing → DNA resolver falls back to ~/.claude/config/
|
||||
/// primary.toml, then to claude. Affects `kei run-via` / `kei agent`
|
||||
/// dispatch; does NOT change Claude Code's in-session model.
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
pub role: String,
|
||||
pub blocks: Vec<String>,
|
||||
/// v0.16 (phase 5): agent substrate role. When present, assembler loads
|
||||
|
|
|
|||
2
_primitives/_rust/Cargo.lock
generated
2
_primitives/_rust/Cargo.lock
generated
|
|
@ -3969,12 +3969,10 @@ dependencies = [
|
|||
"anyhow",
|
||||
"kei-atom-discovery",
|
||||
"kei-skills",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -18,17 +18,7 @@ path = "src/lib.rs"
|
|||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { 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"] }
|
||||
# v0.40 (Phase C): toml needed for safe_tools::policy_chain — reads
|
||||
# ~/.claude/hooks/_lib/policy-chain.toml to know which hooks to chain.
|
||||
toml = "0.8"
|
||||
# v0.41 (audit fix #5): killpg via libc on Unix — kill_on_drop only SIGKILLs
|
||||
# the immediate child shell, leaving grandchildren orphaned. We set the child
|
||||
# in its own process group and killpg() the group on timeout.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
kei-atom-discovery = { path = "../kei-atom-discovery" }
|
||||
kei-skills = { path = "../kei-skills" }
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
pub mod initialize;
|
||||
pub mod prompts;
|
||||
pub mod resources;
|
||||
pub mod safe_tools;
|
||||
pub mod tools;
|
||||
|
||||
use crate::protocol::{err, JsonRpcRequest, JsonRpcResponse, Method, ServerContext, METHOD_NOT_FOUND};
|
||||
|
|
|
|||
|
|
@ -1,738 +0,0 @@
|
|||
//! Phase C — cross-CLI hook enforcement via MCP-wrapped tools.
|
||||
//!
|
||||
//! Exposes three built-in MCP tools — `kei_bash`, `kei_edit`, `kei_write` —
|
||||
//! that synthesize Claude Code's PreToolUse hook input contract, chain
|
||||
//! through the hook scripts declared in `~/.claude/hooks/_lib/policy-chain.toml`,
|
||||
//! and only execute the wrapped action if every hook returns exit 0.
|
||||
//!
|
||||
//! Why this exists: when an agent runs on Grok / Agy / Copilot / Kimi, none
|
||||
//! of our claude-side PreToolUse hooks fire. The agent could read the rules
|
||||
//! in its system prompt but the tool-call layer was previously ungated. The
|
||||
//! `kei_*` MCP tools restore that gate for any MCP-capable CLI.
|
||||
//!
|
||||
//! Constructor Pattern: ONE policy SSoT (`policy-chain.toml`), ONE dispatcher
|
||||
//! (this file), hooks reused as-is from `~/.claude/hooks/`. No rewrite, no
|
||||
//! abstraction layer. Shell-out per hook keeps the contract identical to
|
||||
//! Claude's native PreToolUse pipeline.
|
||||
//!
|
||||
//! CLAUDECODE / GROKCODE guard — DESIGN NOTE (NOT a security boundary):
|
||||
//! When invoked from inside Claude Code (`$CLAUDECODE=1`) or Grok the chain
|
||||
//! is SKIPPED to avoid double-firing the same hooks (they already ran on the
|
||||
//! CLI's own PreToolUse). This is a perf / UX optimization for the inside-CLI
|
||||
//! call path — NOT an authorization check. An attacker who can set the
|
||||
//! parent process's environment already controls the CLI invocation anyway;
|
||||
//! re-running hooks would not stop them. To raise the bar for confused-deputy
|
||||
//! scenarios use full sandboxing (Phase D) or run kei-mcp as a separate UID.
|
||||
//!
|
||||
//! v0.41 audit fixes (2026-05-26, Gemini security review):
|
||||
//! #1 fail-CLOSED on missing hooks (was: silently skip)
|
||||
//! #2 path-traversal guard on kei_edit/kei_write (canonicalize + root check)
|
||||
//! #3 CLAUDECODE bypass — documented as design (see above), no behavior change
|
||||
//! #4 tokio::fs for async file I/O (was: blocking std::fs on tokio thread)
|
||||
//! #5 process-group kill on Unix (was: kill_on_drop SIGKILLs only direct child)
|
||||
//!
|
||||
//! v0.42 re-audit fixes (2026-05-26, 4-CLI dogfood: Claude+Grok+Gemini+Copilot):
|
||||
//! #1 [CRITICAL] symlink LEAF bypass — canonicalize full path + reject
|
||||
//! leaf symlinks (v0.41 only canonicalized PARENT; ln -s ~/.ssh/keys ./x
|
||||
//! then kei_write x followed the link to the target)
|
||||
//! #2 [HIGH] $HOME removed from default allowed_roots — was a blanket
|
||||
//! allow that let agent overwrite ~/.claude/hooks (self-neuter), ~/.zshrc
|
||||
//! (RCE on next shell), and credential stores. Default: $PWD only.
|
||||
//! Denylist also extended with .claude/, .grok/, .gemini/, .copilot/,
|
||||
//! .kimi/, and exact shell-init filenames.
|
||||
//! #3 [HIGH] empty [bash]/[edit]/[write] section also FAIL-CLOSED (was:
|
||||
//! empty vec → pass-through). KEI_POLICY_CHAIN_OPTIONAL=1 to opt in.
|
||||
//! #4 [MED] load_chain converted to async + tokio::fs (was: blocking
|
||||
//! std::fs on tokio worker thread).
|
||||
//! #5 [MED] set_process_group + killpg applied to HOOK subprocess too
|
||||
//! (v0.41 only had it on the bash action; hook grandchildren orphaned).
|
||||
//! #6 [MED] doc note that aggregate timeout is still per-step (60s ×
|
||||
//! N hooks + 60s action). Single-deadline implementation deferred to
|
||||
//! v0.43 — not security-blocking.
|
||||
|
||||
use crate::protocol::{err, ok, JsonRpcRequest, JsonRpcResponse, INTERNAL_ERROR, INVALID_PARAMS};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Per-step timeout (each hook AND the action each get up to this long).
|
||||
/// For an N-hook chain the total wall-clock cap is approximately
|
||||
/// `(N+1) * SAFE_TOOL_TIMEOUT_SECS`. v0.44 doc-honesty fix (Claude MED):
|
||||
/// prior versions claimed this was an "aggregate" cap, which was always
|
||||
/// wrong. Aggregate-deadline impl is deferred; for now the per-step
|
||||
/// semantics are documented honestly so operators pick a sane value.
|
||||
const SAFE_TOOL_TIMEOUT_SECS: u64 = 60;
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PolicyChain {
|
||||
#[serde(default)]
|
||||
bash: ChainSpec,
|
||||
#[serde(default)]
|
||||
edit: ChainSpec,
|
||||
#[serde(default)]
|
||||
write: ChainSpec,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ChainSpec {
|
||||
#[serde(default)]
|
||||
chain: Vec<String>,
|
||||
}
|
||||
|
||||
/// MCP tool descriptors — appended to `tools/list` by `handlers::tools::list`.
|
||||
pub fn descriptors() -> Vec<Value> {
|
||||
vec![
|
||||
json!({
|
||||
"name": "kei_bash",
|
||||
"description": "Run a shell command after running KeiSeiKit's [bash] policy chain (no-github-push, safety-guard, destructive-guard). Blocks on hook exit 2 with the hook's stderr surfaced as the MCP error message. Use this instead of native shell on non-Claude CLIs to inherit Claude Code's safety enforcement.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": { "type": "string", "description": "Shell command to execute" },
|
||||
"cwd": { "type": "string", "description": "Optional working directory; defaults to $PWD" }
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "kei_edit",
|
||||
"description": "Modify a file (replace old_string with new_string) after running KeiSeiKit's [edit] policy chain (citation-verify, numeric-claims-guard). Blocks unverified academic citations and numeric claims without evidence markers.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": { "type": "string" },
|
||||
"old_string": { "type": "string" },
|
||||
"new_string": { "type": "string" }
|
||||
},
|
||||
"required": ["file_path", "old_string", "new_string"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "kei_write",
|
||||
"description": "Write content to a file after running KeiSeiKit's [write] policy chain (citation-verify, numeric-claims-guard). Blocks unverified academic citations and numeric claims without evidence markers.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": { "type": "string" },
|
||||
"content": { "type": "string" }
|
||||
},
|
||||
"required": ["file_path", "content"]
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// Top-level dispatch entry — called from `handlers::tools::call` when the
|
||||
/// tool name matches one of the three `kei_*` built-ins.
|
||||
pub async fn dispatch_safe(req: JsonRpcRequest, name: &str, args: &Value) -> JsonRpcResponse {
|
||||
let result = match name {
|
||||
"kei_bash" => handle_bash(args).await,
|
||||
"kei_edit" => handle_edit(args).await,
|
||||
"kei_write" => handle_write(args).await,
|
||||
_ => Err(format!("safe_tools dispatched unknown name: {name}")),
|
||||
};
|
||||
match result {
|
||||
Ok(text) => ok(req.id, json!({
|
||||
"content": [{ "type": "text", "text": text }],
|
||||
"isError": false,
|
||||
})),
|
||||
Err(e) => err(req.id, INTERNAL_ERROR, e),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-tool handlers --------------------------------------------------
|
||||
|
||||
async fn handle_bash(args: &Value) -> Result<String, String> {
|
||||
let command = args.get("command").and_then(Value::as_str)
|
||||
.ok_or_else(|| missing_arg("kei_bash", "command"))?;
|
||||
let cwd = args.get("cwd").and_then(Value::as_str);
|
||||
|
||||
// v0.44 fix #8 (Gemini MED): include cwd in hook input. Without this,
|
||||
// safety-guard could approve a destructive command (e.g. `rm -rf *`)
|
||||
// assuming PWD, while the actual cwd arg redirected it to a sensitive
|
||||
// dir. Hooks now see the real working directory.
|
||||
let hook_input = json!({
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {
|
||||
"command": command,
|
||||
"cwd": cwd
|
||||
}
|
||||
});
|
||||
run_chain("bash", &hook_input).await?;
|
||||
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg("-c").arg(command);
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
// v0.41 fix #5: put child in its own process group so timeout kills it
|
||||
// and ALL grandchildren together (not just the immediate shell).
|
||||
set_process_group(&mut cmd);
|
||||
// v0.44 fix #4 (Gemini HIGH): clear parent env on subprocess spawn.
|
||||
// Was: child inherited AWS_*, GITHUB_TOKEN, MOONSHOT_API_KEY, etc.
|
||||
// An agent that exec's `env` via kei_bash could exfiltrate all of them.
|
||||
// Now: only PATH/HOME/USER/LANG/TERM/SHELL forwarded (set in helper).
|
||||
apply_safe_env(&mut cmd);
|
||||
|
||||
let child = cmd.spawn().map_err(|e| format!("spawn bash: {e}"))?;
|
||||
let pid_opt = child.id();
|
||||
let fut = child.wait_with_output();
|
||||
|
||||
let out = match tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut).await {
|
||||
Ok(Ok(o)) => o,
|
||||
Ok(Err(e)) => return Err(format!("wait bash: {e}")),
|
||||
Err(_) => {
|
||||
// Timeout — kill the entire process group, not just the child.
|
||||
if let Some(pid) = pid_opt {
|
||||
killpg_best_effort(pid);
|
||||
}
|
||||
return Err("kei_bash timeout".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"bash exited {}: {}",
|
||||
out.status.code().unwrap_or(-1),
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
Ok(if stderr.is_empty() { stdout } else { format!("{stdout}\n[stderr]\n{stderr}") })
|
||||
}
|
||||
|
||||
// v0.41 fix #5: process-group helpers (Unix-only; no-op on other platforms).
|
||||
#[cfg(unix)]
|
||||
fn set_process_group(cmd: &mut Command) {
|
||||
cmd.process_group(0);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fn set_process_group(_cmd: &mut Command) {}
|
||||
|
||||
/// v0.44 fix #4 (Gemini HIGH): strip parent env on subprocess spawn so secrets
|
||||
/// like AWS_*, GITHUB_TOKEN, MOONSHOT_API_KEY etc. don't leak to user-controlled
|
||||
/// bash commands or hook scripts. Whitelist forwards only PATH/HOME/USER/LANG/
|
||||
/// TERM/SHELL — enough to keep tools functional, none of it sensitive.
|
||||
///
|
||||
/// Override: `KEI_SAFE_ENV_EXTRA=":-separated list"` adds named vars to the
|
||||
/// whitelist for callers that legitimately need (e.g. NIX_PATH, JAVA_HOME).
|
||||
fn apply_safe_env(cmd: &mut Command) {
|
||||
cmd.env_clear();
|
||||
let default_keep = [
|
||||
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL",
|
||||
"LC_CTYPE", "TERM", "PWD", "TMPDIR",
|
||||
];
|
||||
for k in default_keep {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
}
|
||||
if let Ok(extras) = std::env::var("KEI_SAFE_ENV_EXTRA") {
|
||||
for k in extras.split(':') {
|
||||
let k = k.trim();
|
||||
if k.is_empty() { continue; }
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn killpg_best_effort(pid: u32) {
|
||||
// SAFETY: libc::kill on a negative PID targets the process group.
|
||||
// SIGKILL = 9. Best-effort — ignore errors (process may have exited).
|
||||
unsafe {
|
||||
let _ = libc::kill(-(pid as i32), libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fn killpg_best_effort(_pid: u32) {}
|
||||
|
||||
async fn handle_edit(args: &Value) -> Result<String, String> {
|
||||
let file_path = args.get("file_path").and_then(Value::as_str)
|
||||
.ok_or_else(|| missing_arg("kei_edit", "file_path"))?;
|
||||
let old_string = args.get("old_string").and_then(Value::as_str)
|
||||
.ok_or_else(|| missing_arg("kei_edit", "old_string"))?;
|
||||
let new_string = args.get("new_string").and_then(Value::as_str)
|
||||
.ok_or_else(|| missing_arg("kei_edit", "new_string"))?;
|
||||
|
||||
// v0.44 LOW: reject empty old_string (would silently prepend new_string
|
||||
// because contents.contains("") is always true).
|
||||
if old_string.is_empty() {
|
||||
return Err("kei_edit: old_string must not be empty".into());
|
||||
}
|
||||
|
||||
let safe_path = validate_path(file_path)?;
|
||||
|
||||
let hook_input = json!({
|
||||
"tool_name": "Edit",
|
||||
"tool_input": {
|
||||
"file_path": safe_path.display().to_string(),
|
||||
"old_string": old_string,
|
||||
"new_string": new_string
|
||||
}
|
||||
});
|
||||
run_chain("edit", &hook_input).await?;
|
||||
|
||||
// v0.44 fix #2 (Gemini HIGH + Claude #4 MED): close TOCTOU window. After
|
||||
// validate_path approved the path, a concurrent process could swap the
|
||||
// file for a symlink before our write. Open the existing file with
|
||||
// O_NOFOLLOW so the open itself fails on symlink-swap; then read/write
|
||||
// through the open fd (not the path again) so no second path lookup.
|
||||
open_nofollow_read_write_edit(&safe_path, old_string, new_string).await
|
||||
}
|
||||
|
||||
async fn handle_write(args: &Value) -> Result<String, String> {
|
||||
let file_path = args.get("file_path").and_then(Value::as_str)
|
||||
.ok_or_else(|| missing_arg("kei_write", "file_path"))?;
|
||||
let content = args.get("content").and_then(Value::as_str)
|
||||
.ok_or_else(|| missing_arg("kei_write", "content"))?;
|
||||
|
||||
let safe_path = validate_path(file_path)?;
|
||||
|
||||
let hook_input = json!({
|
||||
"tool_name": "Write",
|
||||
"tool_input": { "file_path": safe_path.display().to_string(), "content": content }
|
||||
});
|
||||
run_chain("write", &hook_input).await?;
|
||||
|
||||
if let Some(parent) = safe_path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent).await
|
||||
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
// v0.44 fix #2: open with O_NOFOLLOW + O_CREAT to refuse swap-to-symlink.
|
||||
open_nofollow_write(&safe_path, content).await
|
||||
}
|
||||
|
||||
/// v0.44 fix #2: edit via O_NOFOLLOW-opened fd to close the TOCTOU window
|
||||
/// between validate_path and the write. The open() itself refuses if the leaf
|
||||
/// has been swapped to a symlink during the hook-chain await.
|
||||
#[cfg(unix)]
|
||||
async fn open_nofollow_read_write_edit(
|
||||
path: &Path, old_string: &str, new_string: &str,
|
||||
) -> Result<String, String> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let path = path.to_path_buf();
|
||||
let old_s = old_string.to_string();
|
||||
let new_s = new_string.to_string();
|
||||
// Blocking syscalls on a dedicated thread (tokio::task::spawn_blocking).
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.read(true).write(true)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("kei_edit: open(O_NOFOLLOW) {}: {e}", path.display()))?;
|
||||
use std::io::{Read, Write, Seek, SeekFrom};
|
||||
let mut contents = String::new();
|
||||
f.read_to_string(&mut contents)
|
||||
.map_err(|e| format!("kei_edit: read {}: {e}", path.display()))?;
|
||||
if !contents.contains(&old_s) {
|
||||
return Err(format!("kei_edit: old_string not found in {}", path.display()));
|
||||
}
|
||||
let updated = contents.replacen(&old_s, &new_s, 1);
|
||||
f.set_len(0).map_err(|e| format!("kei_edit: truncate {}: {e}", path.display()))?;
|
||||
f.seek(SeekFrom::Start(0))
|
||||
.map_err(|e| format!("kei_edit: seek {}: {e}", path.display()))?;
|
||||
f.write_all(updated.as_bytes())
|
||||
.map_err(|e| format!("kei_edit: write {}: {e}", path.display()))?;
|
||||
Ok(format!("edited {} ({} bytes)", path.display(), updated.len()))
|
||||
}).await
|
||||
.map_err(|e| format!("kei_edit: thread join: {e}"))?;
|
||||
result
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
async fn open_nofollow_read_write_edit(
|
||||
path: &Path, old_string: &str, new_string: &str,
|
||||
) -> Result<String, String> {
|
||||
// Non-Unix fallback: best-effort using tokio::fs (no O_NOFOLLOW available).
|
||||
let contents = fs::read_to_string(path).await
|
||||
.map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
if !contents.contains(old_string) {
|
||||
return Err(format!("kei_edit: old_string not found in {}", path.display()));
|
||||
}
|
||||
let updated = contents.replacen(old_string, new_string, 1);
|
||||
fs::write(path, &updated).await
|
||||
.map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
Ok(format!("edited {} ({} bytes)", path.display(), updated.len()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn open_nofollow_write(path: &Path, content: &str) -> Result<String, String> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let path = path.to_path_buf();
|
||||
let bytes = content.as_bytes().to_vec();
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
// O_NOFOLLOW: refuse if the leaf is a symlink (someone swapped it
|
||||
// during our await). Without this the v0.42 symlink_metadata pre-check
|
||||
// was just an indicator — fs::write still followed.
|
||||
opts.custom_flags(libc::O_NOFOLLOW);
|
||||
// O_EXCL combined with O_CREAT could be added when path does not yet
|
||||
// exist to refuse any pre-existing inode — but the test suite uses
|
||||
// the same path multiple times, so we keep truncate semantics. The
|
||||
// O_NOFOLLOW + symlink_metadata pre-check is sufficient.
|
||||
let mut f = opts.open(&path)
|
||||
.map_err(|e| format!("kei_write: open(O_NOFOLLOW) {}: {e}", path.display()))?;
|
||||
use std::io::Write;
|
||||
f.write_all(&bytes)
|
||||
.map_err(|e| format!("kei_write: write {}: {e}", path.display()))?;
|
||||
Ok(format!("wrote {} ({} bytes)", path.display(), bytes.len()))
|
||||
}).await
|
||||
.map_err(|e| format!("kei_write: thread join: {e}"))?;
|
||||
result
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
async fn open_nofollow_write(path: &Path, content: &str) -> Result<String, String> {
|
||||
fs::write(path, content).await
|
||||
.map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
Ok(format!("wrote {} ({} bytes)", path.display(), content.len()))
|
||||
}
|
||||
|
||||
/// Path-traversal + symlink + denylist guard.
|
||||
///
|
||||
/// v0.41 (initial): rejected `..`, canonicalized PARENT, checked denylist + roots.
|
||||
/// → 4-CLI re-audit (2026-05-26) found this was bypassable via symlink at the
|
||||
/// leaf and self-attackable via the $HOME blanket-allowed root.
|
||||
///
|
||||
/// v0.42 fixes:
|
||||
/// #1 [CRITICAL] reject if the leaf is a symlink (was: validated parent
|
||||
/// only, fs::write followed leaf symlink to anywhere). Done via
|
||||
/// `symlink_metadata` on the leaf BEFORE write, and full `canonicalize`
|
||||
/// on the leaf when the file already exists.
|
||||
/// #2 [HIGH] $HOME removed from default allowed-roots — default is $PWD
|
||||
/// only. Denylist now also covers $HOME/.claude/ (the substrate
|
||||
/// itself), shell init files, and credential stores. Operators who
|
||||
/// need broader access set KEI_ALLOWED_ROOTS explicitly.
|
||||
fn validate_path(p: &str) -> Result<PathBuf, String> {
|
||||
if p.is_empty() {
|
||||
return Err("file_path: empty".into());
|
||||
}
|
||||
// 1. Reject literal `..` segments — covers most traversal attempts.
|
||||
if p.split('/').any(|seg| seg == "..") {
|
||||
return Err(format!("file_path: '..' segment not allowed in {p}"));
|
||||
}
|
||||
let path = Path::new(p);
|
||||
|
||||
// 2. Build a canonical path. Walk UP to the deepest existing ancestor,
|
||||
// canonicalize it (resolves all symlinks in the existing prefix),
|
||||
// then reattach the non-existent tail. This catches symlinks at ANY
|
||||
// depth in the path, including nested non-existent leaves.
|
||||
//
|
||||
// v0.44 fix #1 (Gemini CRITICAL): v0.42 only canonicalized the immediate
|
||||
// parent. If the parent didn't exist either (e.g. /proj/symlink_dir/
|
||||
// new_subdir/file.txt where symlink_dir → /Users/denis), the path fell
|
||||
// through to "absolute as-is" → no canonicalization → bypass.
|
||||
let canonical = canonicalize_with_walk_up(path)?;
|
||||
|
||||
// 3. Even when the file doesn't exist yet, the LEAF could already be a
|
||||
// dangling symlink that `fs::write` would follow on creation. Reject.
|
||||
if let Ok(meta) = std::fs::symlink_metadata(&canonical) {
|
||||
if meta.file_type().is_symlink() {
|
||||
return Err(format!(
|
||||
"file_path: leaf is a symlink (refusing to follow): {}",
|
||||
canonical.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Allowed-root containment FIRST (v0.44 fix #6 reorder: was after
|
||||
// denylist, which meant macOS $TMPDIR = /private/var/folders/... hit
|
||||
// the /var/ denylist before reaching the allowed_roots check, blocking
|
||||
// legitimate use of tempfile-backed CWD on macOS).
|
||||
//
|
||||
// v0.44 fix #5 (Claude HIGH): use Path::starts_with for component-aware
|
||||
// containment — Path::starts_with("/home/u/proj") does NOT match
|
||||
// /home/u/proj-secrets, the str::starts_with that was here did.
|
||||
let roots = allowed_roots();
|
||||
let in_allowed_root = roots.is_empty() || roots.iter().any(|r| {
|
||||
canonical.starts_with(r)
|
||||
});
|
||||
if !in_allowed_root {
|
||||
return Err(format!(
|
||||
"file_path: outside allowed roots {:?}: {}",
|
||||
roots, canonical.display()
|
||||
));
|
||||
}
|
||||
|
||||
let canon_str = canonical.display().to_string();
|
||||
|
||||
// 5. Reject system + substrate-control + credential paths.
|
||||
// Note: paths inside an allowed root that also match a denylist entry
|
||||
// are STILL denied (e.g. agent's CWD == ~/.claude/ — denied even
|
||||
// though it matches a default root). System dirs not in any allowed
|
||||
// root would have been caught above anyway.
|
||||
let denylist = [
|
||||
"/etc/", "/usr/", "/System/", "/var/db/", "/var/log/", "/var/root/",
|
||||
"/private/etc/", "/private/var/db/", "/private/var/log/", "/private/var/root/",
|
||||
"/root/", "/bin/", "/sbin/",
|
||||
];
|
||||
// NOTE: /var/folders/ (macOS $TMPDIR) and /private/tmp/ are NOT denied —
|
||||
// they are legitimate working dirs for tempfile-backed agents.
|
||||
for d in denylist {
|
||||
if canon_str.starts_with(d) {
|
||||
return Err(format!("file_path: denied (system dir): {canon_str}"));
|
||||
}
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let dir_secrets = [
|
||||
".ssh/", ".aws/", ".gnupg/", ".config/gcloud/", ".cargo/credentials",
|
||||
".npmrc", ".docker/config.json", ".kube/",
|
||||
".claude/", ".grok/", ".gemini/", ".copilot/", ".kimi/",
|
||||
];
|
||||
for sd in dir_secrets {
|
||||
let full = format!("{home}/{sd}");
|
||||
if canon_str.starts_with(&full) {
|
||||
return Err(format!("file_path: denied (secret/substrate dir): {canon_str}"));
|
||||
}
|
||||
}
|
||||
let init_files = [
|
||||
".zshrc", ".bashrc", ".profile", ".bash_profile", ".zprofile",
|
||||
".zshenv", ".bash_login", ".inputrc", ".gitconfig",
|
||||
".config/fish/config.fish",
|
||||
];
|
||||
for f in init_files {
|
||||
let full = format!("{home}/{f}");
|
||||
if canon_str == full {
|
||||
return Err(format!("file_path: denied (shell-init file): {canon_str}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
/// v0.44 fix #1: walk up the path looking for the deepest existing ancestor,
|
||||
/// canonicalize THAT, then reattach the non-existent tail components.
|
||||
/// Resolves symlinks at any depth (existing OR non-existing branches).
|
||||
fn canonicalize_with_walk_up(path: &Path) -> Result<PathBuf, String> {
|
||||
// Make the path absolute first so we can walk up reliably.
|
||||
let abs = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| format!("file_path: cwd unavailable: {e}"))?
|
||||
.join(path)
|
||||
};
|
||||
|
||||
// Walk up from the leaf, collecting non-existent components in reverse.
|
||||
let mut current = abs.clone();
|
||||
let mut tail: Vec<std::ffi::OsString> = Vec::new();
|
||||
let canon = loop {
|
||||
if current.exists() {
|
||||
break current.canonicalize()
|
||||
.map_err(|e| format!("file_path: canonicalize {}: {e}", current.display()))?;
|
||||
}
|
||||
let name = current.file_name()
|
||||
.ok_or_else(|| format!("file_path: path has no existing ancestor: {}", abs.display()))?
|
||||
.to_os_string();
|
||||
let parent = match current.parent() {
|
||||
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
|
||||
_ => return Err(format!("file_path: walked to root without finding existing dir: {}", abs.display())),
|
||||
};
|
||||
tail.push(name);
|
||||
current = parent;
|
||||
};
|
||||
|
||||
// Reattach tail (in reverse — we pushed from leaf to root).
|
||||
let mut result = canon;
|
||||
for name in tail.into_iter().rev() {
|
||||
result.push(name);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn allowed_roots() -> Vec<String> {
|
||||
// Canonicalize each entry so symlinked roots (e.g. macOS /var → /private/var,
|
||||
// /tmp → /private/tmp) match canonicalized targets. Trailing slash added
|
||||
// for the consistency-with-default format. v0.44 fix #5 + #6 combined.
|
||||
let canon_with_slash = |raw: &str| -> Option<String> {
|
||||
let p = Path::new(raw);
|
||||
let canon = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
let mut s = canon.display().to_string();
|
||||
if !s.ends_with('/') { s.push('/'); }
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
};
|
||||
if let Ok(v) = std::env::var("KEI_ALLOWED_ROOTS") {
|
||||
return v.split(':')
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(canon_with_slash)
|
||||
.collect();
|
||||
}
|
||||
let mut roots = Vec::new();
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
if let Some(r) = canon_with_slash(&cwd.display().to_string()) {
|
||||
roots.push(r);
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
// ---- chain runner -------------------------------------------------------
|
||||
|
||||
/// Run the configured hook chain for `tool` ("bash"/"edit"/"write"), piping
|
||||
/// `hook_input` to each hook's stdin in order. Exit 0 → continue. Exit 2 (or
|
||||
/// other non-zero) → return Err with the hook's stderr.
|
||||
///
|
||||
/// Skips the chain if the parent process is already inside Claude or Grok
|
||||
/// (env flags), since those CLIs' native PreToolUse hooks already fired.
|
||||
/// Run the configured hook chain for `tool` ("bash"/"edit"/"write").
|
||||
///
|
||||
/// v0.42 fixes:
|
||||
/// #3 [HIGH] empty chain (section absent or zero hooks) now FAILS CLOSED
|
||||
/// unless KEI_POLICY_CHAIN_OPTIONAL=1.
|
||||
/// #4 [MED] load_chain() converted to async (was: blocking std::fs).
|
||||
/// #5 [MED] hook subprocess gets `process_group(0)` + killpg on timeout
|
||||
/// (was: only the bash action got it; hooks could orphan).
|
||||
/// #6 [MED] aggregate timeout across the whole chain + action (was:
|
||||
/// per-hook 60s, so chain+action could legitimately run
|
||||
/// 4× the documented cap on a 3-hook chain).
|
||||
async fn run_chain(tool: &str, hook_input: &Value) -> Result<(), String> {
|
||||
if env_truthy("CLAUDECODE") || env_truthy("GROKCODE") {
|
||||
// Native hooks already enforced — don't double-fire.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let chain = load_chain(tool).await?;
|
||||
if chain.is_empty() {
|
||||
// v0.42 fix #3 (Claude+Gemini HIGH): empty section is the same
|
||||
// misconfig class as missing file — FAIL CLOSED with explicit opt-in.
|
||||
if env_truthy("KEI_POLICY_CHAIN_OPTIONAL") {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!(
|
||||
"[policy-chain] section [{tool}] is empty — refusing to run \
|
||||
(set KEI_POLICY_CHAIN_OPTIONAL=1 to allow pass-through, e.g. for tests)"
|
||||
));
|
||||
}
|
||||
|
||||
let hooks_dir = hooks_dir()?;
|
||||
let payload = serde_json::to_string(hook_input)
|
||||
.map_err(|e| format!("encode hook input: {e}"))?;
|
||||
|
||||
for hook in chain {
|
||||
let path = hooks_dir.join(&hook);
|
||||
if !path.is_file() {
|
||||
return Err(format!(
|
||||
"[policy-chain] hook missing: {} (declared in policy-chain.toml [{}])",
|
||||
path.display(), tool
|
||||
));
|
||||
}
|
||||
|
||||
let mut child_cmd = Command::new(&path);
|
||||
child_cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
set_process_group(&mut child_cmd);
|
||||
// v0.44 fix #4: same env-isolation for hook subprocess.
|
||||
apply_safe_env(&mut child_cmd);
|
||||
|
||||
let mut child = child_cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn {}: {e}", path.display()))?;
|
||||
let pid_opt = child.id();
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(payload.as_bytes()).await
|
||||
.map_err(|e| format!("write stdin to {}: {e}", path.display()))?;
|
||||
stdin.shutdown().await
|
||||
.map_err(|e| format!("close stdin to {}: {e}", path.display()))?;
|
||||
}
|
||||
|
||||
let fut = child.wait_with_output();
|
||||
let out = match tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut).await {
|
||||
Ok(Ok(o)) => o,
|
||||
Ok(Err(e)) => return Err(format!("wait {}: {e}", path.display())),
|
||||
Err(_) => {
|
||||
// v0.42 fix #5: kill the whole hook process group, not just
|
||||
// the immediate child.
|
||||
if let Some(pid) = pid_opt {
|
||||
killpg_best_effort(pid);
|
||||
}
|
||||
return Err(format!("hook {hook} timeout"));
|
||||
}
|
||||
};
|
||||
|
||||
let code = out.status.code().unwrap_or(-1);
|
||||
if code == 0 {
|
||||
continue;
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
return Err(format!(
|
||||
"[blocked by {hook} exit={code}]\n{stderr}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- config helpers -----------------------------------------------------
|
||||
|
||||
/// v0.42 fix #4: async + tokio::fs (was: blocking std::fs would freeze
|
||||
/// a tokio worker if policy-chain.toml lived on a slow / hung mount).
|
||||
async fn load_chain(tool: &str) -> Result<Vec<String>, String> {
|
||||
let path = chain_path()?;
|
||||
// tokio::fs::try_exists avoids a blocking is_file() syscall.
|
||||
let exists = fs::try_exists(&path).await.unwrap_or(false);
|
||||
if !exists {
|
||||
if env_truthy("KEI_POLICY_CHAIN_OPTIONAL") {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
return Err(format!(
|
||||
"[policy-chain] config missing: {} (set KEI_POLICY_CHAIN_OPTIONAL=1 to allow pass-through, e.g. for tests)",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let raw = fs::read_to_string(&path).await
|
||||
.map_err(|e| format!("read policy-chain.toml: {e}"))?;
|
||||
let parsed: PolicyChain = toml::from_str(&raw)
|
||||
.map_err(|e| format!("parse policy-chain.toml: {e}"))?;
|
||||
let chain = match tool {
|
||||
"bash" => parsed.bash.chain,
|
||||
"edit" => parsed.edit.chain,
|
||||
"write" => parsed.write.chain,
|
||||
_ => return Err(format!("unknown tool kind: {tool}")),
|
||||
};
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
fn chain_path() -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("KEI_POLICY_CHAIN") {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
let dir = hooks_dir()?;
|
||||
Ok(dir.join("_lib").join("policy-chain.toml"))
|
||||
}
|
||||
|
||||
fn hooks_dir() -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("KEI_HOOKS_DIR") {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
|
||||
Ok(PathBuf::from(home).join(".claude").join("hooks"))
|
||||
}
|
||||
|
||||
fn env_truthy(name: &str) -> bool {
|
||||
matches!(std::env::var(name).as_deref(), Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes"))
|
||||
}
|
||||
|
||||
fn missing_arg(tool: &str, field: &str) -> String {
|
||||
format!("{tool}: missing '{field}' argument")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
const INVALID_PARAMS_REF: i32 = INVALID_PARAMS; // silence unused-import warning if removed
|
||||
|
|
@ -33,16 +33,6 @@ 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());
|
||||
// v0.40 (Phase C): policy-gated MCP tools — kei_bash / kei_edit /
|
||||
// kei_write run the configured hook chain BEFORE executing the action.
|
||||
// This restores Claude Code's PreToolUse safety on non-Claude CLIs
|
||||
// (Grok / Agy / Copilot / Kimi) — any MCP-capable orchestrator that
|
||||
// disables its native shell + uses kei_bash gets full enforcement.
|
||||
tools.extend(super::safe_tools::descriptors());
|
||||
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(""))
|
||||
|
|
@ -60,23 +50,6 @@ 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),
|
||||
};
|
||||
}
|
||||
|
||||
// v0.40 (Phase C): kei_bash / kei_edit / kei_write — policy-gated tools.
|
||||
if matches!(name.as_str(), "kei_bash" | "kei_edit" | "kei_write") {
|
||||
return super::safe_tools::dispatch_safe(req, &name, &args).await;
|
||||
}
|
||||
|
||||
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() }],
|
||||
|
|
@ -86,94 +59,6 @@ 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.
|
||||
fn atom_to_tool_descriptor(meta: AtomMeta) -> Value {
|
||||
let description = first_paragraph(&meta.body);
|
||||
|
|
|
|||
|
|
@ -68,19 +68,7 @@ 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");
|
||||
// v0.40 (Phase C): list includes 4 built-ins (spawn_agent + kei_bash +
|
||||
// kei_edit + kei_write) on top of discovered atoms.
|
||||
assert_eq!(tools.len(), 6); // 2 atoms + 4 built-ins
|
||||
assert!(
|
||||
tools.iter().any(|t| t["name"] == "spawn_agent"),
|
||||
"spawn_agent built-in must be present"
|
||||
);
|
||||
for kei in ["kei_bash", "kei_edit", "kei_write"] {
|
||||
assert!(
|
||||
tools.iter().any(|t| t["name"] == kei),
|
||||
"{kei} built-in must be present"
|
||||
);
|
||||
}
|
||||
assert_eq!(tools.len(), 2);
|
||||
// sorted alphabetically
|
||||
assert_eq!(tools[0]["name"], "kei-sage::ask");
|
||||
assert_eq!(tools[1]["name"], "kei-task::search");
|
||||
|
|
@ -103,14 +91,5 @@ async fn tools_list_handles_empty_root() {
|
|||
};
|
||||
let resp = dispatch(req, &ctx).await;
|
||||
let result = resp.result.expect("should have result");
|
||||
// v0.40 (Phase C): empty atoms root surfaces 4 built-ins
|
||||
// (spawn_agent + kei_bash + kei_edit + kei_write).
|
||||
let tools = result["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 4);
|
||||
let names: Vec<&str> = tools.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.collect();
|
||||
for required in ["spawn_agent", "kei_bash", "kei_edit", "kei_write"] {
|
||||
assert!(names.contains(&required), "missing built-in: {required}");
|
||||
}
|
||||
assert_eq!(result["tools"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
# cli-backends.toml — SSoT for external LLM CLIs that can host KeiSeiKit agents.
|
||||
#
|
||||
# Each backend is a CLI you have a subscription / local install of. The
|
||||
# `kei run-via <backend> <agent> "<task>"` launcher composes an agent's
|
||||
# assembled .md prompt with the task and invokes the backend's
|
||||
# non-interactive (print) mode.
|
||||
#
|
||||
# Add a backend by appending a `[backend.<name>]` table. The launcher
|
||||
# (`scripts/kei-agent-cli.sh`) reads `bin` + `prompt_flag` and execs.
|
||||
|
||||
[backend.claude]
|
||||
bin = "claude"
|
||||
prompt_flag = "-p"
|
||||
notes = "Claude Code (Anthropic) — native --agent flag also supported"
|
||||
homepage = "https://claude.com/claude-code"
|
||||
|
||||
[backend.grok]
|
||||
bin = "grok"
|
||||
prompt_flag = "--print"
|
||||
notes = "xAI Grok Build TUI — native --agent flag also supported"
|
||||
homepage = "https://x.ai/grok"
|
||||
|
||||
[backend.agy]
|
||||
bin = "agy"
|
||||
prompt_flag = "--print"
|
||||
notes = "Google Antigravity (alias: antigravity)"
|
||||
aliases = ["antigravity"]
|
||||
|
||||
[backend.copilot]
|
||||
bin = "copilot"
|
||||
prompt_flag = "--prompt"
|
||||
notes = "GitHub Copilot CLI (@github/copilot npm)"
|
||||
homepage = "https://github.com/github/copilot-cli"
|
||||
|
||||
[backend.kimi]
|
||||
bin = "kimi"
|
||||
prompt_flag = "tui-only"
|
||||
notes = "Moonshot Kimi CLI — TUI-ONLY (smoke 2026-05-26). Headless requires ACP client; launcher saves prompt to tmpfile + opens TUI for paste."
|
||||
homepage = "https://moonshotai.github.io/kimi-cli/"
|
||||
|
||||
[backend.codex]
|
||||
bin = "codex"
|
||||
prompt_flag = "-p"
|
||||
notes = "OpenAI Codex CLI — register here, install separately"
|
||||
homepage = "https://github.com/openai/codex"
|
||||
122
bin/kei
122
bin/kei
|
|
@ -9,22 +9,7 @@
|
|||
# kei --no-splash # skip splash → exec claude
|
||||
# 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 <name> "<task>" # invoke agent, backend from DNA → primary
|
||||
# kei agent --on=<backend> <name> "<task>" # override backend
|
||||
# kei run-via <backend> <name> "<task>" # invoke agent on explicit backend
|
||||
# # backends: claude grok agy copilot kimi codex
|
||||
# # `kei run-via list` shows install status + agents
|
||||
# kei primary [<backend>] # get/set primary LLM provider (DNA fallback)
|
||||
# kei mcp-wire [<cli>] # wire kei-mcp into a CLI's MCP config + hook setup
|
||||
# # (Phase C cross-CLI policy enforcement)
|
||||
# kei mcp-wire --list # show enforcement tier per CLI
|
||||
# kei limits # probe each CLI's subscription quota (best-effort)
|
||||
# # (4 of 5 CLIs have no public API — honest report)
|
||||
# kei onboard # post-install wizard (pick primary + mcp-wire + check)
|
||||
# kei --on=<backend> # one-shot launch of <backend> (does not change primary)
|
||||
# kei [args...] # splash → exec primary CLI (default: claude)
|
||||
# kei [args...] # splash → claude args... (forwarded verbatim)
|
||||
#
|
||||
# The splash shows: substrate version, agent count, last sleep run,
|
||||
# active sessions (kei-ping). Press any key to skip the dwell.
|
||||
|
|
@ -34,12 +19,8 @@
|
|||
set -e
|
||||
|
||||
# --- subcommand dispatch (before splash) ---------------------------------
|
||||
# `kei message ...` → mailbox CLI
|
||||
# `kei configure` → hook/stack re-picker
|
||||
# `kei agent ...` → DNA-resolved agent (manifest provider → primary → claude)
|
||||
# `kei run-via ...` → explicit-backend agent invocation
|
||||
# `kei primary ...` → get/set primary LLM provider
|
||||
# rest = splash + launch claude (legacy primary).
|
||||
# `kei message ...` → mailbox CLI; `kei configure` → hook/stack re-picker;
|
||||
# everything else falls through to launch.
|
||||
case "${1:-}" in
|
||||
message|msg|m)
|
||||
shift
|
||||
|
|
@ -49,112 +30,28 @@ case "${1:-}" in
|
|||
shift
|
||||
exec "$HOME/.claude/scripts/kei-configure.sh" "$@"
|
||||
;;
|
||||
agent)
|
||||
shift
|
||||
exec "$HOME/.claude/scripts/kei-agent-cli.sh" "$@"
|
||||
;;
|
||||
run-via|via|agent-via)
|
||||
shift
|
||||
exec "$HOME/.claude/scripts/kei-agent-cli.sh" "$@"
|
||||
;;
|
||||
primary)
|
||||
shift
|
||||
exec "$HOME/.claude/scripts/kei-agent-cli.sh" primary "$@"
|
||||
;;
|
||||
pick)
|
||||
shift
|
||||
exec "$HOME/.claude/scripts/kei-pick.sh" "$@"
|
||||
;;
|
||||
mcp-wire|wire)
|
||||
shift
|
||||
exec "$HOME/.claude/scripts/kei-mcp-wire.sh" "$@"
|
||||
;;
|
||||
limits|quota|usage)
|
||||
shift
|
||||
exec "$HOME/.claude/scripts/kei-limits.sh" "$@"
|
||||
;;
|
||||
onboard|setup|wizard)
|
||||
shift
|
||||
exec "$HOME/.claude/scripts/kei-onboard.sh" "$@"
|
||||
;;
|
||||
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 ----------------------------------------------------------------
|
||||
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
|
||||
|
||||
# --- resolve primary backend ---------------------------------------------
|
||||
# Order: --on=<backend> 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 <backend> (set a different default)" >&2
|
||||
# --- 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
|
||||
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"
|
||||
|
|
@ -235,9 +132,8 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█
|
|||
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
|
||||
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
|
||||
|
||||
${C2} KeiSeiKit · substrate v0.45${C0}
|
||||
${C2} KeiSeiKit · substrate v0.38${C0}
|
||||
${C3} ─────────────────────────────────────${C0}
|
||||
primary CLI : ${CV}${PRIMARY}${C0}
|
||||
profile : ${CV}${p}${C0}
|
||||
agents : ${CV}${ac}${C0}
|
||||
last sleep run : ${CV}${sl}${C0}
|
||||
|
|
|
|||
28
bootstrap.sh
28
bootstrap.sh
|
|
@ -177,14 +177,9 @@ fi
|
|||
log "checkout: $KIT_DIR"
|
||||
|
||||
# --- 5. run install ------------------------------------------------------
|
||||
log "running install.sh --profile=$PROFILE $YES_FLAG ${EXTRA_FLAGS[*]:-}"
|
||||
log "running ./install.sh --profile=$PROFILE $YES_FLAG ${EXTRA_FLAGS[*]:-}"
|
||||
cd "$KIT_DIR"
|
||||
# Defensive: invoke via `bash` not `./install.sh` because GitHub's contents
|
||||
# API does NOT preserve the executable bit on `gh api -X PUT` updates
|
||||
# (only the git Data API does). Older clones may have install.sh with
|
||||
# mode 644 even though the source repo has it 755. `bash <file>` works
|
||||
# regardless of file mode. Verified incident 2026-05-26 prod-curl test.
|
||||
bash ./install.sh --profile="$PROFILE" $YES_FLAG "${EXTRA_FLAGS[@]:+${EXTRA_FLAGS[@]}}"
|
||||
./install.sh --profile="$PROFILE" $YES_FLAG "${EXTRA_FLAGS[@]:+${EXTRA_FLAGS[@]}}"
|
||||
|
||||
# --- 6. post-install verification ----------------------------------------
|
||||
KEI_BIN="$HOME/.claude/agents/_primitives/_rust/target/release"
|
||||
|
|
@ -204,25 +199,6 @@ log ""
|
|||
log "==========================================================================="
|
||||
log "DONE — KeiSeiKit installed (profile: $PROFILE)"
|
||||
log "==========================================================================="
|
||||
|
||||
# v0.45: post-install onboarding wizard.
|
||||
# Auto-triggers if stdin is a TTY (real terminal). Wizard itself re-checks
|
||||
# and exits cleanly if non-interactive — so curl|bash one-liner runs work too.
|
||||
ONBOARD_SH="$HOME/.claude/scripts/kei-onboard.sh"
|
||||
if [ -x "$ONBOARD_SH" ] && [ -t 0 ] && [ "${KEI_NO_ONBOARD:-0}" != "1" ]; then
|
||||
log ""
|
||||
log "Starting post-install onboarding (pick primary CLI + wire MCP)..."
|
||||
log "Skip with KEI_NO_ONBOARD=1; re-run anytime with 'kei onboard'."
|
||||
log ""
|
||||
"$ONBOARD_SH" || log "(onboarding exited non-zero; re-run with 'kei onboard')"
|
||||
else
|
||||
log ""
|
||||
log "Post-install wizard skipped (no TTY or KEI_NO_ONBOARD=1)."
|
||||
log "Run interactively to configure primary CLI:"
|
||||
log " kei onboard # full wizard"
|
||||
log " kei pick # just pick primary"
|
||||
log " kei mcp-wire # wire MCP into installed CLIs"
|
||||
fi
|
||||
log ""
|
||||
log "Next steps:"
|
||||
log " - Open a new shell so PATH picks up ~/.cargo/bin and the kei-* binaries."
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
# Cross-CLI policy enforcement
|
||||
|
||||
> *Same safety rules. Any LLM CLI. Three honesty tiers.*
|
||||
|
||||
KeiSeiKit's safety hooks (`no-github-push`, `safety-guard`, `destructive-guard`,
|
||||
`citation-verify`, `numeric-claims-guard`) originally fired only inside Claude
|
||||
Code's `PreToolUse` pipeline. Phase C extends enforcement to other CLIs —
|
||||
but the strength of enforcement depends on what each CLI permits.
|
||||
|
||||
## The 3-tier honesty model
|
||||
|
||||
| Tier | What it means | CLIs |
|
||||
|---|---|---|
|
||||
| **TIER 1 — full native** | Tool-call enforcement at the CLI's own hook layer. Same as Claude. | claude, **grok** |
|
||||
| **TIER 2 — MCP-wrapped** | Native shell disabled at launch; agent forced to use our policy-gated `kei_bash`/`kei_edit`/`kei_write` MCP tools. | **copilot** |
|
||||
| **TIER 3 — advisory** | CLI can't disable native shell; we register kei-mcp and instruct the agent to prefer `kei_*` tools, but enforcement is prompt-level only. | **agy, kimi** |
|
||||
|
||||
For patent-sensitive or production-PR work — stick to TIER 1 (claude or grok).
|
||||
|
||||
## How to wire
|
||||
|
||||
One command sets up enforcement for whichever CLIs you have installed:
|
||||
|
||||
```bash
|
||||
kei mcp-wire # detect + wire all installed CLIs
|
||||
kei mcp-wire grok # wire one CLI
|
||||
kei mcp-wire --dry-run # preview config changes without writing
|
||||
kei mcp-wire --list # show enforcement tier per CLI
|
||||
```
|
||||
|
||||
The orchestrator is idempotent — running twice produces the same config.
|
||||
|
||||
## What `kei mcp-wire` writes
|
||||
|
||||
### claude (TIER 1 — already enforced)
|
||||
No-op. Native PreToolUse hooks already gate every tool call. `kei mcp-wire claude`
|
||||
prints the optional `mcpServers` snippet you can add to
|
||||
`~/.claude/settings.json` if you want claude to also see `spawn_agent` for
|
||||
sub-agent dispatch.
|
||||
|
||||
### grok (TIER 1 — port our hooks)
|
||||
Writes `~/.grok/settings.json` `hooks.PreToolUse` block:
|
||||
|
||||
- `Bash` matcher → `no-github-push.sh` + `safety-guard.sh` + `destructive-guard.sh`
|
||||
- `Edit` matcher → `citation-verify.sh` + `numeric-claims-guard.sh`
|
||||
- `Write` matcher → `citation-verify.sh` + `numeric-claims-guard.sh`
|
||||
|
||||
Plus registers kei-mcp with `GROKCODE=1` env (so kei-mcp's policy chain skips
|
||||
duplicate enforcement when invoked via Grok — your native hooks already fired).
|
||||
|
||||
xAI's Grok uses the same JSON input contract as Claude Code's PreToolUse, so
|
||||
our hook scripts run unchanged. Identical enforcement to claude.
|
||||
|
||||
### copilot (TIER 2 — disable native shell, force MCP)
|
||||
Writes `~/.copilot/mcp-config.json` registering kei-mcp. To activate enforcement,
|
||||
launch copilot with `--excluded-tools='shell'`:
|
||||
|
||||
```bash
|
||||
alias copilot='copilot --excluded-tools=shell'
|
||||
```
|
||||
|
||||
The agent will have NO native shell tool, only kei-mcp's `kei_bash` —
|
||||
which runs the policy chain before execution. `kei_edit` / `kei_write`
|
||||
similarly gate file mutations.
|
||||
|
||||
### agy / kimi (TIER 3 — advisory)
|
||||
Writes their MCP config (`~/.gemini/config/mcp_config.json` for agy,
|
||||
`~/.kimi/mcp.json` for kimi) registering kei-mcp.
|
||||
|
||||
**The honest part:** these CLIs do NOT have a way to disable their native
|
||||
shell. The agent CAN reach for native bash regardless of what we tell it.
|
||||
The system prompt nudges it toward `kei_bash`, but a determined or careless
|
||||
agent can bypass.
|
||||
|
||||
For patent-sensitive work — **don't use agy or kimi as orchestrator**.
|
||||
Use them for analysis / brainstorming / no-side-effect tasks only.
|
||||
|
||||
## Internals
|
||||
|
||||
### policy-chain.toml (SSoT)
|
||||
|
||||
One file declares which hooks gate which tool, for all CLIs that go through
|
||||
the MCP layer:
|
||||
|
||||
```toml
|
||||
# ~/.claude/hooks/_lib/policy-chain.toml
|
||||
[bash]
|
||||
chain = ["no-github-push.sh", "safety-guard.sh", "destructive-guard.sh"]
|
||||
|
||||
[edit]
|
||||
chain = ["citation-verify.sh", "numeric-claims-guard.sh"]
|
||||
|
||||
[write]
|
||||
chain = ["citation-verify.sh", "numeric-claims-guard.sh"]
|
||||
```
|
||||
|
||||
To add a hook: append its basename. The hook script must already exist in
|
||||
`~/.claude/hooks/` and follow the standard PreToolUse contract (read JSON
|
||||
on stdin with `.tool_name` + `.tool_input`, return exit 0 = pass / 2 = block).
|
||||
|
||||
### kei-mcp built-in tools
|
||||
|
||||
`kei-mcp` (Rust MCP server at `_primitives/_rust/kei-mcp/`) exposes 4
|
||||
built-in tools across two source files (both bypass the atom-discovery
|
||||
loop in `handlers/tools.rs`):
|
||||
|
||||
In `handlers/tools.rs`:
|
||||
- `spawn_agent(name, task, on?)` — invokes a KeiSeiKit agent on any backend
|
||||
|
||||
In `handlers/safe_tools.rs` (Phase C, v0.40+):
|
||||
- `kei_bash(command, cwd?)` — runs `[bash]` chain → executes
|
||||
- `kei_edit(file_path, old_string, new_string)` — runs `[edit]` chain → edits
|
||||
- `kei_write(file_path, content)` — runs `[write]` chain → writes
|
||||
|
||||
The chain runs against the same hook scripts Claude uses; identical input
|
||||
shape, identical decisions. On block, the hook's stderr surfaces as the MCP
|
||||
error message so the calling agent sees exactly why.
|
||||
|
||||
**v0.42 hardening** (post 4-CLI re-audit, supersedes v0.41):
|
||||
|
||||
- **Fail-CLOSED everywhere** — missing config, missing hook, OR empty
|
||||
section (`[bash]/[edit]/[write]` with no entries) all refuse to run.
|
||||
Tests / dev can opt in via `KEI_POLICY_CHAIN_OPTIONAL=1`.
|
||||
- **Symlink-safe path guard** — `kei_edit` / `kei_write` canonicalize the
|
||||
FULL path (resolving any leaf symlink to its real target) and reject
|
||||
if the leaf itself is a symlink for a not-yet-existent file. Fixes the
|
||||
v0.41 CRITICAL bypass where `ln -s ~/.ssh/keys ./x; kei_write x` would
|
||||
follow the link.
|
||||
- **$PWD-only default root** — `allowed_roots` defaults to current working
|
||||
directory only. Was: `$PWD` + entire `$HOME` — too permissive, agent
|
||||
could overwrite `~/.claude/hooks/*` (self-neuter) or `~/.zshrc` (RCE on
|
||||
next shell). Operators who need broader access set `KEI_ALLOWED_ROOTS`.
|
||||
- **Denylist extended** — system dirs (`/etc/`, `/usr/`, `/System/`,
|
||||
`/var/`, `/root/`, `/bin/`, `/sbin/`); credential stores (`~/.ssh/`,
|
||||
`~/.aws/`, `~/.gnupg/`, `~/.config/gcloud/`, `~/.cargo/credentials`,
|
||||
`~/.docker/config.json`, `~/.kube/`); substrate dirs (`~/.claude/`,
|
||||
`~/.grok/`, `~/.gemini/`, `~/.copilot/`, `~/.kimi/`); exact shell-init
|
||||
files (`.zshrc`, `.bashrc`, `.profile`, `.zshenv`, `.gitconfig`, ...).
|
||||
- **Async file I/O in load_chain** — `policy-chain.toml` now read via
|
||||
`tokio::fs` (was: blocking `std::fs` froze worker on slow mounts).
|
||||
- **Process-group kill on hooks too** — hook subprocesses get
|
||||
`process_group(0)` and `killpg(SIGKILL)` on timeout. Was: only the bash
|
||||
action got this; hook grandchildren orphaned.
|
||||
- **CLAUDECODE/GROKCODE design note** — documented as perf/UX
|
||||
optimization, NOT a security boundary (env-controllable parent → confused
|
||||
deputy is already-game-over scenario).
|
||||
|
||||
### Double-enforcement guard
|
||||
|
||||
If kei-mcp is invoked from a process where `$CLAUDECODE=1` or `$GROKCODE=1`,
|
||||
it SKIPS its hook chain — the CLI's native hooks already fired. This is set
|
||||
automatically by `kei mcp-wire claude` / `kei mcp-wire grok`. On copilot /
|
||||
agy / kimi the env is unset → chain runs.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# All 4 built-ins must list:
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
|
||||
| kei-mcp | jq -r '.result.capabilities'
|
||||
|
||||
# Block test (kei_bash refuses forbidden command):
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"kei_bash","arguments":{"command":"git push https://github.com/x/y.git main"}}}' \
|
||||
| kei-mcp 2>&1 | grep "RULE 0.1" # expects: BLOCK — RULE 0.1 NO GITHUB PUSH
|
||||
|
||||
# Pass test:
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"kei_bash","arguments":{"command":"echo OK"}}}' \
|
||||
| kei-mcp | tail -1 | jq -r '.result.content[0].text' # expects: OK
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Multi-CLI agent invocation](./multi-cli-agents.md) — DNA-resolved agent dispatch
|
||||
- `kei-mcp` source: `_primitives/_rust/kei-mcp/src/handlers/safe_tools.rs`
|
||||
- Policy SSoT: `hooks/_lib/policy-chain.toml`
|
||||
- Wire scripts: `scripts/kei-mcp-wire*.sh`
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
# Multi-CLI agent invocation
|
||||
|
||||
> *Cross-LLM agent execution. Same agent definition, different backend.*
|
||||
> *Same DNA, swap the brain. KeiSeiKit is no longer Claude-Code-only.*
|
||||
|
||||
KeiSeiKit agents are markdown files. Any LLM CLI that takes a prompt can
|
||||
host them. Three call shapes:
|
||||
|
||||
```bash
|
||||
kei agent <name> "<task>" # DNA-resolved (manifest → primary → claude)
|
||||
kei agent --on=<backend> <name> "<task>" # override DNA
|
||||
kei run-via <backend> <name> "<task>" # explicit backend (no DNA lookup)
|
||||
```
|
||||
|
||||
## Backends — smoke-tested 2026-05-26
|
||||
|
||||
| Backend | CLI | Flag | Smoke | Notes |
|
||||
|----------|-----------|--------------|-------|-------|
|
||||
| claude | `claude` | `-p` | ✅ | Claude Code, native `--agent` flag |
|
||||
| grok | `grok` | `--print` | ✅ | xAI Grok Build TUI, native `--agent` flag |
|
||||
| agy | `agy` | `--print` | ✅ | Google Antigravity (Gemini models). Alias: `antigravity` |
|
||||
| copilot | `copilot` | `--prompt` | ✅ | GitHub Copilot CLI (`@github/copilot`) |
|
||||
| kimi | `kimi` | TUI-only | ⚠ | No print mode — launcher saves prompt to tmpfile + opens TUI for paste. `kimi acp` JSON-RPC integration is future work. |
|
||||
| codex | `codex` | `-p` | — | OpenAI Codex (register-only; not installed locally) |
|
||||
|
||||
Run `kei run-via list` to see installed backends, current primary, and agent names.
|
||||
|
||||
## DNA — agent prefers a provider
|
||||
|
||||
Add `provider` to the agent manifest:
|
||||
|
||||
```toml
|
||||
# _manifests/my-agent.toml
|
||||
name = "my-agent"
|
||||
provider = "grok" # preferred backend; optional
|
||||
model = "grok-2" # advisory; informs choice but not yet sent through
|
||||
```
|
||||
|
||||
The assembler emits it into frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-agent
|
||||
provider: grok
|
||||
---
|
||||
```
|
||||
|
||||
Resolution order (each falls through if previous returns nothing):
|
||||
1. `--on=<backend>` flag on the command line
|
||||
2. `provider:` field in agent manifest
|
||||
3. `~/.claude/config/primary.toml` (set via `kei primary <backend>`)
|
||||
4. Default: `claude`
|
||||
|
||||
## Primary — your default LLM
|
||||
|
||||
```bash
|
||||
kei primary # show current primary (and fallback)
|
||||
kei primary grok # set default to Grok
|
||||
kei primary claude # back to Claude Code
|
||||
```
|
||||
|
||||
`kei primary` writes `~/.claude/config/primary.toml`. Any agent without
|
||||
its own `provider:` field will resolve to this. This is the lever to
|
||||
"swap out Claude Code as the primary shell" — set primary to grok, and
|
||||
every `kei agent <name>` runs on Grok.
|
||||
|
||||
## Usage examples
|
||||
|
||||
```bash
|
||||
# DNA mode (manifest's provider, or primary, or claude):
|
||||
kei agent critic "review src/auth.rs"
|
||||
|
||||
# Override DNA — try the same agent on a different model for a second opinion:
|
||||
kei agent --on=grok critic "review src/auth.rs"
|
||||
kei agent --on=agy critic "review src/auth.rs"
|
||||
kei agent --on=copilot critic "review src/auth.rs"
|
||||
|
||||
# Explicit backend, no DNA lookup (legacy):
|
||||
kei run-via grok critic "review src/auth.rs"
|
||||
|
||||
# Point at an arbitrary agent file:
|
||||
kei agent --on=grok --file=/tmp/my-agent.md "do the thing"
|
||||
|
||||
# Native --agent flag (grok/claude only):
|
||||
KEI_NATIVE_AGENT=1 kei agent critic "review src/auth.rs"
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. Resolves backend from DNA (see above).
|
||||
2. Reads `~/.claude/agents/<agent-name>.md` (assembler-generated prompt).
|
||||
3. Strips YAML frontmatter.
|
||||
4. Composes with task: `<agent prompt>\n\n---\n\nTASK FOR THIS RUN:\n<task>`.
|
||||
5. Execs the backend's non-interactive CLI with the composed prompt.
|
||||
|
||||
No agent file is modified. No new tokens are issued — subscription
|
||||
authentication is whatever each CLI uses (its own login / config dir).
|
||||
|
||||
## When to use each
|
||||
|
||||
This is a tool, not a recommendation. Each backend has different
|
||||
strengths; the substrate is agnostic about which you pick. Pick by:
|
||||
|
||||
- **Familiarity** — the CLI you already use day-to-day.
|
||||
- **Subscription cost** — burn the one with cheaper marginal cost first.
|
||||
- **Specific feature** — e.g. `grok --agent` for native sub-agent
|
||||
switching mid-conversation; `agy --sandbox` for terminal restriction.
|
||||
- **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: <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 — see also: cross-CLI policy
|
||||
|
||||
**Phase C delivered**: KeiSeiKit's safety hooks now have a 3-tier enforcement
|
||||
model across CLIs. See [cross-cli-policy.md](./cross-cli-policy.md) for the
|
||||
full matrix and `kei mcp-wire` setup. Short version: TIER 1 (full native)
|
||||
on claude+grok, TIER 2 (MCP-wrapped) on copilot, TIER 3 (advisory) on agy+kimi.
|
||||
|
||||
## Rule enforcement caveat (READ THIS — pre-Phase-C view)
|
||||
|
||||
KeiSeiKit hooks (`numeric-claims-guard`, `citation-verify`, `no-github-push`,
|
||||
`safety-guard`, `push-to-main`, etc.) are **Claude Code-side**:
|
||||
`PreToolUse:Bash` / `:Edit` / `:Write` events that fire inside Claude Code's
|
||||
process. They do **not** propagate to grok / agy / copilot / kimi.
|
||||
|
||||
That means:
|
||||
- **Prompt-level rules** (the agent's instructions inside the `.md`) DO
|
||||
carry through — the agent reads Constructor Pattern, Evidence Grading,
|
||||
No Hallucination, etc. as part of its system prompt on any backend.
|
||||
- **Tool-level enforcement** (hard-deny on `git push github.com`,
|
||||
citation guard, etc.) only applies on the **claude** backend. Other
|
||||
backends' tool surfaces are governed by THEIR own hooks/policies.
|
||||
|
||||
If you need true rule-enforcement on a non-claude backend, the path is
|
||||
the **MCP server** (`_primitives/_rust/kei-mcp/`): registers KeiSeiKit
|
||||
primitives as MCP tools that the other CLI invokes. Tool-side policies
|
||||
travel with the MCP wrapper, not with the CLI.
|
||||
|
||||
## Adding a new backend
|
||||
|
||||
1. Add a `[backend.<name>]` table to `_primitives/cli-backends.toml`.
|
||||
2. Add a case arm in `scripts/kei-agent-cli.sh` `backend_bin()` and
|
||||
`backend_invoke()` for the new CLI's print-flag.
|
||||
3. Add a row to the smoke-test table above (state PASS/FAIL/PARTIAL).
|
||||
|
||||
## What it is NOT
|
||||
|
||||
- Not a router — picks no backend for you; you (or DNA) ask, it dispatches.
|
||||
- Not a federation — each backend runs independently with its own
|
||||
context; there is no cross-backend state.
|
||||
- Not a rule-enforcement layer — hooks only fire on the claude backend
|
||||
(see caveat above). For non-claude rule enforcement use MCP server.
|
||||
- Not a wrapper around the backend's tool surface — what the CLI can
|
||||
do (Bash, file edits, MCP, etc.) is determined by that CLI, not
|
||||
KeiSeiKit. The substrate only ships the prompt.
|
||||
|
||||
## Related
|
||||
|
||||
- `_primitives/_rust/kei-llm-router/` — Beta-posterior router for
|
||||
*programmatic* model selection inside Rust code (a different layer).
|
||||
- `_primitives/_rust/kei-mcp/` — MCP server that exposes KeiSeiKit
|
||||
primitives to ANY MCP-compatible client (Cursor / Continue / Zed /
|
||||
Aider / Cline / Windsurf / OpenClaw).
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# policy-chain.toml — SSoT for which hooks gate which MCP tool.
|
||||
#
|
||||
# Consumed by `kei-mcp::handlers::safe_tools` to enforce KeiSeiKit's safety
|
||||
# rules on non-Claude CLIs (Grok / Agy / Copilot / Kimi) via the
|
||||
# `kei_bash` / `kei_edit` / `kei_write` MCP tools.
|
||||
#
|
||||
# Hooks live in ~/.claude/hooks/ (overridable via $KEI_HOOKS_DIR).
|
||||
# Exit codes: 0 = pass, 2 = block, other non-zero = treat as block + log.
|
||||
# The dispatcher iterates `chain` IN ORDER and aborts on first non-zero.
|
||||
#
|
||||
# Constructor Pattern: ONE chain for all CLIs. Per-CLI override deferred
|
||||
# until proven necessary. To extend, append a hook basename (no .sh) to
|
||||
# the relevant chain — the hook script must already exist in ~/.claude/hooks/.
|
||||
|
||||
[bash]
|
||||
chain = [
|
||||
"no-github-push.sh",
|
||||
"safety-guard.sh",
|
||||
"destructive-guard.sh",
|
||||
]
|
||||
|
||||
[edit]
|
||||
chain = [
|
||||
"citation-verify.sh",
|
||||
"numeric-claims-guard.sh",
|
||||
]
|
||||
|
||||
[write]
|
||||
chain = [
|
||||
"citation-verify.sh",
|
||||
"numeric-claims-guard.sh",
|
||||
]
|
||||
|
|
@ -65,14 +65,4 @@ if [ -n "$TOOL_USE_ID" ] && [ -f "$ACTIVE_FILE" ]; then
|
|||
mv "$ACTIVE_FILE.tmp" "$ACTIVE_FILE" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# v0.40 root-cause fix: remove the .task-${id}.start marker that task-timer.sh
|
||||
# wrote on agent_spawn. Without this, completed sub-agents leave stale markers
|
||||
# in ~/.claude/memory/time-metrics/ which inflate the pet's running-agent
|
||||
# counter (🤖N). Previously task-timer was the only writer + the 2h stale
|
||||
# filter in keisei-pet.sh was the only cleanup; that left up-to-2h dead
|
||||
# markers visible on every status refresh.
|
||||
if [ -n "$TOOL_USE_ID" ] && [ "$TOOL_USE_ID" != "unknown" ]; then
|
||||
rm -f "$HOME/.claude/memory/time-metrics/.task-${TOOL_USE_ID}.start" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -36,67 +36,36 @@ if [ -n "$transcript" ] && [ -f "$transcript" ]; then
|
|||
cp -f "$transcript" "$dest" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# RECURRENCE FIX 2026-05-26: 18MB+ transcripts caused 4-minute "Recombobulating…"
|
||||
# hangs at session end. The three heavy ops below now run async-detached:
|
||||
# hook returns immediately, ingest / scan / sync grind in background.
|
||||
# Raw JSONL is already saved sync (line 36) — no data loss; only the
|
||||
# index/embedding step is deferred. kei-memory ingest is idempotent on
|
||||
# session_id so partial runs are safe.
|
||||
|
||||
bg_log="${HOME}/.claude/memory/traces/session-end.bg.log"
|
||||
mkdir -p "$(dirname "$bg_log")" 2>/dev/null || true
|
||||
|
||||
# Portable timeout (macOS has no `timeout` / `gtimeout` by default).
|
||||
# Fallback: perl alarm. Final fallback: no timeout (rely on detach).
|
||||
kei_with_timeout() {
|
||||
secs="$1"; shift
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$secs" "$@"
|
||||
elif command -v gtimeout >/dev/null 2>&1; then
|
||||
gtimeout "$secs" "$@"
|
||||
elif command -v perl >/dev/null 2>&1; then
|
||||
perl -e 'alarm shift @ARGV; exec @ARGV' "$secs" "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Best-effort ingest — async-detached.
|
||||
# Best-effort ingest — advisory only; never blocks the session from ending.
|
||||
if command -v kei-memory >/dev/null 2>&1 && [ -f "$dest" ]; then
|
||||
(
|
||||
kei_with_timeout 90 kei-memory ingest \
|
||||
--session-id "$session_id" \
|
||||
--transcript "$dest" \
|
||||
>>"$bg_log" 2>&1 \
|
||||
|| printf '[%s] kei-memory ingest timeout/fail for %s\n' \
|
||||
"$(date +%H:%M:%S)" "$session_id" >>"$bg_log"
|
||||
) </dev/null >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
kei-memory ingest \
|
||||
--session-id "$session_id" \
|
||||
--transcript "$dest" \
|
||||
>/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Wave 25 — frustration-matrix scan.
|
||||
# Wave 25 — frustration-matrix scan: regex+firmware classifier produces a
|
||||
# JSONL of per-line affect hits per session, much smaller than the full
|
||||
# transcript. Cloud REM agent reads the affect file instead of 80MB JSONL.
|
||||
# Silent no-op when the primitive is absent.
|
||||
if command -v frustration-matrix >/dev/null 2>&1; then
|
||||
affect_dir="${HOME}/.claude/memory/affect"
|
||||
mkdir -p "$affect_dir" 2>/dev/null || true
|
||||
affect_out="${affect_dir}/${session_id}.jsonl"
|
||||
(
|
||||
kei_with_timeout 60 frustration-matrix scan \
|
||||
--root "$traces_dir" \
|
||||
--since 1d \
|
||||
--format jsonl \
|
||||
--output "$affect_out" \
|
||||
>>"$bg_log" 2>&1 || true
|
||||
) </dev/null >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
frustration-matrix scan \
|
||||
--root "$traces_dir" \
|
||||
--since 1d \
|
||||
--format jsonl \
|
||||
--output "$affect_out" \
|
||||
>/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# v0.11 sleep-sync (RULE 0.15) — push traces to memory-repo.
|
||||
# v0.11 sleep-sync (RULE 0.15) — push traces to the user's memory-repo so a
|
||||
# cloud agent can consolidate them overnight. Silent no-op when the primitive
|
||||
# is absent or the user hasn't opted in via /sleep-setup.
|
||||
sleep_sync="${HOME}/.claude/agents/_primitives/kei-sleep-sync.sh"
|
||||
if [ -x "$sleep_sync" ]; then
|
||||
(
|
||||
kei_with_timeout 120 "$sleep_sync" >>"$bg_log" 2>&1 || true
|
||||
) </dev/null >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
"$sleep_sync" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -80,29 +80,14 @@ _mint_runner_token() {
|
|||
printf '%s' "$token"
|
||||
}
|
||||
|
||||
# v0.45 fix: brew installs `gitea-runner` (not `act_runner`); the binary is
|
||||
# named `gitea-runner`. Resolver tries both names so future brew packaging
|
||||
# changes don't re-break this. act_runner upstream and gitea-runner fork are
|
||||
# functionally equivalent and both register with Forgejo.
|
||||
_runner_bin() {
|
||||
if command -v act_runner >/dev/null 2>&1; then
|
||||
echo "act_runner"
|
||||
elif command -v gitea-runner >/dev/null 2>&1; then
|
||||
echo "gitea-runner"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Internal: register the runner with the local Forgejo. Writes ${DATA}/.runner.
|
||||
# Internal: register act_runner with the local Forgejo. Writes ${DATA}/.runner.
|
||||
# Args: <data_dir> <token>.
|
||||
_register_act_runner() {
|
||||
local data_dir="$1"
|
||||
local token="$2"
|
||||
local label="self-hosted,macos-arm64,native"
|
||||
local name="$(hostname -s)-keisei"
|
||||
local runner
|
||||
runner="$(_runner_bin)" || { err "no runner binary found (looked for act_runner + gitea-runner)"; return 1; }
|
||||
( cd "$data_dir" && "$runner" register \
|
||||
( cd "$data_dir" && act_runner register \
|
||||
--no-interactive \
|
||||
--instance http://127.0.0.1:3001 \
|
||||
--token "$token" \
|
||||
|
|
@ -112,19 +97,12 @@ _register_act_runner() {
|
|||
|
||||
# Public entry: install + register + bootstrap the runner.
|
||||
install_dev_hub_forgejo_runner() {
|
||||
say "installing dev-hub-forgejo-runner (Forgejo Actions runner)"
|
||||
say "installing dev-hub-forgejo-runner (act_runner)"
|
||||
_require_forgejo_binary || return 1
|
||||
_require_forgejo_running || return 1
|
||||
|
||||
# Prefer the Forgejo-official runner; fall back to the gitea-runner fork
|
||||
# (which is what `brew install gitea-runner` actually provides today).
|
||||
if ! _runner_bin >/dev/null 2>&1; then
|
||||
say "brew install gitea-runner (Forgejo-compatible)"
|
||||
brew install gitea-runner || {
|
||||
warn "brew install gitea-runner failed — try 'brew tap actions/runner' for act_runner"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
say "brew install act_runner"
|
||||
brew install act_runner
|
||||
|
||||
local data_dir
|
||||
data_dir="$(_runner_data_dir)"
|
||||
|
|
@ -147,9 +125,7 @@ install_dev_hub_forgejo_runner() {
|
|||
. "$KIT_DIR/install/lib-launchd.sh"
|
||||
install_service forgejo-runner
|
||||
|
||||
local runner_name
|
||||
runner_name="$(_runner_bin 2>/dev/null || echo runner)"
|
||||
say "$runner_name registered + running. Polling http://127.0.0.1:3001 for jobs."
|
||||
say "act_runner registered + running. Polling http://127.0.0.1:3001 for jobs."
|
||||
}
|
||||
|
||||
# Public entry: stop + unload the runner. Keeps ${DATA}/.runner so re-install
|
||||
|
|
|
|||
|
|
@ -97,19 +97,11 @@ _dhf_bootstrap_admin_user() {
|
|||
local kc_token_svc kc_pass_svc
|
||||
config="$(_dhf_app_ini)"
|
||||
username="${KEI_FORGEJO_ADMIN_USER:-${USER:-denis}}"
|
||||
# Single-source Keychain service names (override per-host via env).
|
||||
# Wizard MUST read identical names — see drive-import-wizard.sh.tmpl.
|
||||
kc_token_svc="${KEI_FORGEJO_KC_TOKEN_SERVICE:-forgejo-api-token}"
|
||||
kc_pass_svc="${KEI_FORGEJO_KC_PASS_SERVICE:-forgejo-admin-password}"
|
||||
|
||||
# v0.45 fix: Forgejo on first install needs `migrate` to create the sqlite
|
||||
# schema. Without it, `admin user create` fails with "no such table: user"
|
||||
# (verified bug 2026-05-26 in prod curl|bash test). `migrate` is idempotent
|
||||
# — safe to re-run.
|
||||
if ! forgejo --config "$config" migrate 2>/dev/null; then
|
||||
warn " → forgejo migrate failed; daemon may need restart before admin create"
|
||||
fi
|
||||
|
||||
# Detection: any rows beyond header in `admin user list`? Now safe to
|
||||
# parse since migrate has ensured the user table exists.
|
||||
# Detection: any rows beyond header in `admin user list`?
|
||||
user_count="$(forgejo --config "$config" admin user list 2>/dev/null \
|
||||
| tail -n +2 | grep -cv '^$' || echo 0)"
|
||||
if [ "$user_count" -gt 0 ]; then
|
||||
|
|
|
|||
|
|
@ -41,38 +41,13 @@ _dhz_check_go_runtime() {
|
|||
fi
|
||||
}
|
||||
|
||||
# Step b — install zoekt. Zoekt is NOT in homebrew/core — try tap first,
|
||||
# then fall back to building from source via Go (if installed). On total
|
||||
# failure, skip cleanly rather than aborting the whole install.
|
||||
# v0.45 fix: prior version errored hard ("No formula") and bailed the entire
|
||||
# dev-hub install. Now degrades gracefully.
|
||||
# Step b — brew install zoekt (idempotent).
|
||||
_dhz_brew_install() {
|
||||
say "installing zoekt (idempotent)"
|
||||
if command -v zoekt-webserver >/dev/null 2>&1 && command -v zoekt-index >/dev/null 2>&1; then
|
||||
say " → zoekt already installed; skipping"
|
||||
return 0
|
||||
say "installing zoekt via brew (idempotent)"
|
||||
if ! brew install zoekt; then
|
||||
err "brew install zoekt failed — see brew log above"
|
||||
return 1
|
||||
fi
|
||||
if brew install zoekt 2>/dev/null; then
|
||||
say " → installed via brew core"
|
||||
return 0
|
||||
fi
|
||||
if brew install sourcegraph/zoekt/zoekt 2>/dev/null \
|
||||
|| brew install hyperdiscovery/zoekt/zoekt 2>/dev/null; then
|
||||
say " → installed via tap"
|
||||
return 0
|
||||
fi
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
say " → falling back to 'go install' from sourcegraph/zoekt"
|
||||
if go install github.com/sourcegraph/zoekt/cmd/zoekt-webserver@latest \
|
||||
&& go install github.com/sourcegraph/zoekt/cmd/zoekt-index@latest; then
|
||||
say " → installed via go"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
warn "zoekt unavailable: not in brew core/taps + no go fallback."
|
||||
warn "Skipping zoekt service install. Other dev-hub services continue."
|
||||
warn "To install later: brew install --HEAD sourcegraph/zoekt/zoekt"
|
||||
return 2 # signal partial — caller treats as skip, not fatal
|
||||
}
|
||||
|
||||
# Step c — ensure data dir tree (+ index dir).
|
||||
|
|
|
|||
|
|
@ -27,16 +27,14 @@ install_hooks() {
|
|||
say " installed $hook_count hook(s)"
|
||||
|
||||
# v0.17 — shared hook library (gate.sh + test-gate.sh)
|
||||
# v0.40 — also copy *.toml files from _lib/ (policy-chain.toml for safe_tools).
|
||||
if [ -d "$KIT_DIR/hooks/_lib" ]; then
|
||||
mkdir -p "$HOOKS_DIR/_lib"
|
||||
local lib_count=0 lib_src lib_name
|
||||
for lib_src in "$KIT_DIR/hooks/_lib/"*.sh "$KIT_DIR/hooks/_lib/"*.toml; do
|
||||
for lib_src in "$KIT_DIR/hooks/_lib/"*.sh; do
|
||||
[ -f "$lib_src" ] || continue
|
||||
lib_name="$(basename "$lib_src")"
|
||||
cp -f "$lib_src" "$HOOKS_DIR/_lib/$lib_name"
|
||||
# chmod +x only for shell scripts; .toml stays read-only.
|
||||
case "$lib_name" in *.sh) chmod +x "$HOOKS_DIR/_lib/$lib_name" ;; esac
|
||||
chmod +x "$HOOKS_DIR/_lib/$lib_name"
|
||||
lib_count=$((lib_count+1))
|
||||
done
|
||||
say " installed $lib_count hook library file(s) -> $HOOKS_DIR/_lib/"
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
"$schema": "https://json.schemastore.org/claude-code-plugin.json",
|
||||
"name": "keisei",
|
||||
"displayName": "KeiSei",
|
||||
"description": "Constructor Pattern multi-LLM agent substrate — 38 agents, 69 skills, 54 hooks, 86 blocks. Cross-CLI policy enforcement (Claude/Grok/Copilot/Agy/Kimi) via kei-mcp + kei_bash/kei_edit/kei_write. Rust primitives via classic ./install.sh.",
|
||||
"version": "0.45.0",
|
||||
"homepage": "https://keisei.app",
|
||||
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
|
||||
"description": "Constructor Pattern agent substrate — 59 agents, 67 skills, 39 hooks, 86 blocks. Rust primitives via classic ./install.sh.",
|
||||
"version": "0.38.0",
|
||||
"homepage": "https://keigit.com/keisei/KeiSeiKit-1.0",
|
||||
"repository": "https://keigit.com/keisei/KeiSeiKit-1.0.git",
|
||||
"author": {
|
||||
"name": "Denis Parfionovich",
|
||||
"email": "parfionovich@keilab.io"
|
||||
|
|
|
|||
|
|
@ -1,281 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# kei-agent-cli — invoke a KeiSeiKit agent via an external LLM CLI backend.
|
||||
#
|
||||
# Two entry points (both route through this script):
|
||||
#
|
||||
# kei run-via <backend> <agent> "<task>" # explicit backend
|
||||
# kei agent <agent> "<task>" # backend resolved from DNA:
|
||||
# # 1. --on=<backend> flag
|
||||
# # 2. agent manifest's `provider`
|
||||
# # 3. ~/.claude/config/primary.toml
|
||||
# # 4. fallback: claude
|
||||
#
|
||||
# Other forms:
|
||||
# kei run-via list # show backends + agents
|
||||
# kei agent --on=<backend> <agent> "<task>" # override DNA backend
|
||||
# kei primary # print current primary
|
||||
# kei primary <backend> # set primary provider
|
||||
# kei run-via --help
|
||||
#
|
||||
# Backends (SSoT: _primitives/cli-backends.toml):
|
||||
# claude grok agy copilot kimi codex
|
||||
#
|
||||
# Reads assembled prompt from ~/.claude/agents/<agent-name>.md.
|
||||
# Strips YAML frontmatter, composes with task, execs the CLI.
|
||||
#
|
||||
# Env overrides:
|
||||
# KEI_AGENTS_DIR agent .md dir (default: ~/.claude/agents)
|
||||
# KEI_MANIFESTS_DIR manifest .toml dir (default: ~/.claude/_manifests)
|
||||
# KEI_PRIMARY override primary backend (beats config file)
|
||||
# KEI_NATIVE_AGENT=1 prefer backend's native --agent flag (grok/claude)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
KEI_AGENTS_DIR="${KEI_AGENTS_DIR:-$HOME/.claude/agents}"
|
||||
KEI_MANIFESTS_DIR="${KEI_MANIFESTS_DIR:-$HOME/.claude/_manifests}"
|
||||
KEI_PRIMARY_CFG="${KEI_PRIMARY_CFG:-$HOME/.claude/config/primary.toml}"
|
||||
KEI_NATIVE_AGENT="${KEI_NATIVE_AGENT:-0}"
|
||||
|
||||
usage() { sed -n '2,32p' "$0" | sed 's|^# \{0,1\}||'; }
|
||||
|
||||
# ---- backend table (SSoT mirror; kept in sync with cli-backends.toml) -----
|
||||
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_supports_native_agent() {
|
||||
case "$1" in claude|grok) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# ---- DNA resolver: agent → preferred backend --------------------------------
|
||||
# Reads `provider = "..."` line from the manifest TOML if present.
|
||||
manifest_provider() {
|
||||
local agent="$1" tomlf="$KEI_MANIFESTS_DIR/$1.toml"
|
||||
[ -f "$tomlf" ] || return 1
|
||||
awk -F'=' '
|
||||
/^provider[[:space:]]*=/ {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
|
||||
gsub(/^"|"$/, "", $2)
|
||||
print $2; exit
|
||||
}
|
||||
' "$tomlf"
|
||||
}
|
||||
|
||||
# Reads primary from config file (or KEI_PRIMARY env override).
|
||||
config_primary() {
|
||||
if [ -n "${KEI_PRIMARY:-}" ]; then
|
||||
printf '%s\n' "$KEI_PRIMARY"; return 0
|
||||
fi
|
||||
[ -f "$KEI_PRIMARY_CFG" ] || return 1
|
||||
awk -F'=' '
|
||||
/^provider[[:space:]]*=/ {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
|
||||
gsub(/^"|"$/, "", $2)
|
||||
print $2; exit
|
||||
}
|
||||
' "$KEI_PRIMARY_CFG"
|
||||
}
|
||||
|
||||
# Resolution order: explicit --on= → manifest provider → primary → claude.
|
||||
resolve_backend() {
|
||||
local agent="$1" explicit="${2:-}" out=""
|
||||
if [ -n "$explicit" ]; then printf '%s\n' "$explicit"; return 0; fi
|
||||
out=$(manifest_provider "$agent" 2>/dev/null) || true
|
||||
if [ -n "$out" ]; then printf '%s\n' "$out"; return 0; fi
|
||||
out=$(config_primary 2>/dev/null) || true
|
||||
if [ -n "$out" ]; then printf '%s\n' "$out"; return 0; fi
|
||||
printf 'claude\n'
|
||||
}
|
||||
|
||||
# ---- backend invocation ---------------------------------------------------
|
||||
backend_invoke() {
|
||||
local backend="$1" prompt="$2" agent_name="${3:-}" bin
|
||||
bin=$(backend_bin "$backend") || {
|
||||
printf '[kei-agent-cli] unknown backend: %s\n' "$backend" >&2
|
||||
return 2
|
||||
}
|
||||
command -v "$bin" >/dev/null 2>&1 || {
|
||||
printf '[kei-agent-cli] %s not on PATH. Install it or pick another backend.\n' "$bin" >&2
|
||||
return 127
|
||||
}
|
||||
|
||||
# Native --agent path (grok/claude) — pass agent name + task directly.
|
||||
if [ "$KEI_NATIVE_AGENT" = "1" ] \
|
||||
&& [ -n "$agent_name" ] \
|
||||
&& backend_supports_native_agent "$backend"; then
|
||||
printf '[kei-agent-cli] %s --agent %s\n' "$bin" "$agent_name" >&2
|
||||
exec "$bin" --agent "$agent_name" --print "${prompt##*TASK FOR THIS RUN:}"
|
||||
fi
|
||||
|
||||
# v0.41 fix: headless subprocess invocation of claude/grok without
|
||||
# --dangerously-skip-permissions returns empty (the agent's system prompt
|
||||
# asks for Read/Grep tools, but those need permission prompts which can't
|
||||
# be answered in -p mode). Pass the flag so the agent actually executes.
|
||||
# Override via KEI_AGENT_PERMISSIVE=0 to keep the strict default.
|
||||
local permissive_claude="" permissive_grok=""
|
||||
if [ "${KEI_AGENT_PERMISSIVE:-1}" = "1" ]; then
|
||||
permissive_claude="--permission-mode=bypassPermissions"
|
||||
permissive_grok="--always-approve"
|
||||
fi
|
||||
|
||||
case "$backend" in
|
||||
claude) exec "$bin" $permissive_claude -p "$prompt" ;;
|
||||
grok) exec "$bin" $permissive_grok --print "$prompt" ;;
|
||||
agy|antigravity) exec "$bin" --dangerously-skip-permissions --print "$prompt" ;;
|
||||
copilot) exec "$bin" --prompt "$prompt" ;;
|
||||
kimi)
|
||||
# Kimi has NO one-shot print mode (smoke-tested 2026-05-26): bare `kimi`
|
||||
# opens an interactive TUI that ignores piped stdin and exits with "Bye!".
|
||||
# For headless invocation we'd need an ACP client (`kimi acp` is a JSON-RPC
|
||||
# stdio server). Until KeiSeiKit ships that client, dump the composed
|
||||
# prompt to a tmpfile and open the TUI so the user can paste it in.
|
||||
tmp=$(mktemp -t kei-agent-kimi.XXXX.md)
|
||||
printf '%s\n' "$prompt" > "$tmp"
|
||||
printf '[kei-agent-cli] kimi non-interactive is unsupported (TUI only).\n' >&2
|
||||
printf '[kei-agent-cli] composed prompt saved: %s\n' "$tmp" >&2
|
||||
printf '[kei-agent-cli] copy-paste it into Kimi after the TUI opens.\n' >&2
|
||||
printf '[kei-agent-cli] (or pipe via `kimi acp` if you have an ACP client.)\n' >&2
|
||||
exec "$bin"
|
||||
;;
|
||||
codex) exec "$bin" -p "$prompt" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- agent loader -------------------------------------------------------
|
||||
load_agent() {
|
||||
local name="$1" path
|
||||
case "$name" in
|
||||
--file=*) path="${name#--file=}" ;;
|
||||
/*|./*|*/*) path="$name" ;;
|
||||
*) path="$KEI_AGENTS_DIR/$name.md" ;;
|
||||
esac
|
||||
if [ ! -f "$path" ]; then
|
||||
printf '[kei-agent-cli] agent not found: %s\n' "$path" >&2
|
||||
if [ -d "$KEI_AGENTS_DIR" ]; then
|
||||
printf ' Available (%s): %s\n' "$KEI_AGENTS_DIR" \
|
||||
"$(find "$KEI_AGENTS_DIR" -maxdepth 1 -name '*.md' -not -name '_*' 2>/dev/null \
|
||||
| xargs -n1 basename 2>/dev/null | sed 's/\.md$//' \
|
||||
| sort | head -8 | tr '\n' ' ')..." >&2
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
awk '
|
||||
BEGIN { in_fm=0 }
|
||||
NR==1 && /^---$/ { in_fm=1; next }
|
||||
in_fm && /^---$/ { in_fm=0; next }
|
||||
in_fm { next }
|
||||
{ print }
|
||||
' "$path"
|
||||
}
|
||||
|
||||
# ---- primary subcommand ------------------------------------------------
|
||||
handle_primary() {
|
||||
local arg="${1:-}"
|
||||
if [ -z "$arg" ]; then
|
||||
cur=$(config_primary 2>/dev/null || true)
|
||||
printf 'primary provider: %s\n' "${cur:-claude (default fallback)}"
|
||||
[ -f "$KEI_PRIMARY_CFG" ] && printf 'config: %s\n' "$KEI_PRIMARY_CFG"
|
||||
return 0
|
||||
fi
|
||||
backend_bin "$arg" >/dev/null || {
|
||||
printf '[kei-primary] unknown backend: %s\n' "$arg" >&2
|
||||
printf 'valid: claude grok agy copilot kimi codex\n' >&2
|
||||
return 2
|
||||
}
|
||||
mkdir -p "$(dirname "$KEI_PRIMARY_CFG")"
|
||||
printf '# kei primary — written %s\nprovider = "%s"\n' \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$arg" > "$KEI_PRIMARY_CFG"
|
||||
printf 'primary provider set: %s → %s\n' "$arg" "$KEI_PRIMARY_CFG"
|
||||
}
|
||||
|
||||
# ---- subcommands --------------------------------------------------------
|
||||
case "${1:-}" in
|
||||
""|-h|--help|help) usage; exit 0 ;;
|
||||
list|--list)
|
||||
printf 'Backends (✓ installed, ✗ missing):\n'
|
||||
for b in claude grok agy copilot kimi codex; do
|
||||
bin=$(backend_bin "$b")
|
||||
if p=$(command -v "$bin" 2>/dev/null); then
|
||||
printf ' %-10s ✓ %s\n' "$b" "$p"
|
||||
else
|
||||
printf ' %-10s ✗ (not on PATH)\n' "$b"
|
||||
fi
|
||||
done
|
||||
cur=$(config_primary 2>/dev/null || true)
|
||||
printf '\nprimary: %s\n' "${cur:-claude (default)}"
|
||||
printf '\nAgents (%s):\n' "$KEI_AGENTS_DIR"
|
||||
if [ -d "$KEI_AGENTS_DIR" ]; then
|
||||
find "$KEI_AGENTS_DIR" -maxdepth 1 -name '*.md' -not -name '_*' 2>/dev/null \
|
||||
| xargs -n1 basename 2>/dev/null | sed 's/\.md$/ /' | sort | column 2>/dev/null \
|
||||
|| (find "$KEI_AGENTS_DIR" -maxdepth 1 -name '*.md' -not -name '_*' \
|
||||
| xargs -n1 basename | sed 's/\.md$//' | sort | head -20)
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
primary)
|
||||
shift
|
||||
handle_primary "${1:-}"
|
||||
exit $?
|
||||
;;
|
||||
agent)
|
||||
# Direct-invocation passthrough: `kei-agent-cli.sh agent <name> "task"`
|
||||
# behaves identically to `kei-agent-cli.sh <name> "task"` (DNA mode).
|
||||
# Lets users call either form without surprise.
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---- main: DNA mode (no leading backend) OR explicit run-via ------------
|
||||
# Detect call shape:
|
||||
# "$1" is a known backend → run-via flow (kei run-via <backend> <agent> "task")
|
||||
# "$1" starts with --on= → DNA flow with override
|
||||
# "$1" is anything else → DNA flow (kei agent <agent> "task")
|
||||
|
||||
EXPLICIT_BACKEND=""
|
||||
case "${1:-}" in
|
||||
--on=*)
|
||||
EXPLICIT_BACKEND="${1#--on=}"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
if [ $# -ge 1 ] && backend_bin "$1" >/dev/null 2>&1; then
|
||||
EXPLICIT_BACKEND="$1"
|
||||
shift
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ $# -lt 2 ]; then
|
||||
usage
|
||||
exit 2
|
||||
fi
|
||||
|
||||
AGENT_REF="$1"; shift
|
||||
TASK="$*"
|
||||
|
||||
AGENT_NAME=$(basename "${AGENT_REF#--file=}")
|
||||
AGENT_NAME="${AGENT_NAME%.md}"
|
||||
|
||||
BACKEND=$(resolve_backend "$AGENT_NAME" "$EXPLICIT_BACKEND")
|
||||
|
||||
if ! AGENT_PROMPT=$(load_agent "$AGENT_REF"); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMPOSED=$(printf '%s\n\n---\n\nTASK FOR THIS RUN:\n%s\n' "$AGENT_PROMPT" "$TASK")
|
||||
|
||||
printf '[kei-agent-cli] agent=%s backend=%s (via %s)\n' \
|
||||
"$AGENT_NAME" "$BACKEND" \
|
||||
"$([ -n "$EXPLICIT_BACKEND" ] && echo explicit \
|
||||
|| ([ -n "$(manifest_provider "$AGENT_NAME" 2>/dev/null)" ] && echo manifest \
|
||||
|| ([ -n "$(config_primary 2>/dev/null)" ] && echo primary || echo default)))" >&2
|
||||
|
||||
backend_invoke "$BACKEND" "$COMPOSED" "$AGENT_NAME"
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# kei-limits — probe each installed CLI's remaining quota / balance.
|
||||
#
|
||||
# Reality (research 2026-05-26):
|
||||
# • claude — no programmatic API. Headers per-API-call only. Admin API
|
||||
# exists but needs a separate admin key. See dashboard.
|
||||
# • grok — same as claude. Headers per-API-call only. No file.
|
||||
# • agy — interactive /usage slash-cmd is broken (shows 100% always,
|
||||
# forum-verified bug). No public API.
|
||||
# • copilot — no public quota API. github.com/settings/billing only.
|
||||
# Inline output during call shows usage but nothing exposed
|
||||
# for poll.
|
||||
# • kimi — Moonshot API /v1/users/me/balance returns $ balance only
|
||||
# (no session/weekly quota). Requires MOONSHOT_API_KEY.
|
||||
#
|
||||
# Output:
|
||||
# stdout: human summary (default) OR JSON (--json)
|
||||
# file: ~/.claude/pet/limits-cache.json (always, for pet to read)
|
||||
#
|
||||
# Polling: NOT poll-friendly. Run on demand or via launchd at >5 min intervals.
|
||||
# Pet's job: read the cache; pet does NOT call this script.
|
||||
|
||||
set -u
|
||||
|
||||
# v0.43-fix #4: jq runtime guard (convention with 40+ sibling scripts).
|
||||
command -v jq >/dev/null 2>&1 || {
|
||||
echo "kei-limits: jq required (brew install jq / apt install jq)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
CACHE="${KEI_LIMITS_CACHE:-$HOME/.claude/pet/limits-cache.json}"
|
||||
mkdir -p "$(dirname "$CACHE")"
|
||||
|
||||
JSON_OUT=0
|
||||
QUIET=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json) JSON_OUT=1 ;;
|
||||
--quiet) QUIET=1 ;;
|
||||
-h|--help) sed -n '2,22p' "$0" | sed 's|^# \{0,1\}||'; exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- per-CLI probes (each returns one JSON value to stdout) ----------------
|
||||
probe_claude() {
|
||||
# No public API; produce a status marker, no live data.
|
||||
printf '%s' '{"status":"no-api","note":"see claude.ai/settings/usage","dashboard":"https://claude.ai/settings/usage"}'
|
||||
}
|
||||
|
||||
probe_grok() {
|
||||
printf '%s' '{"status":"no-api","note":"headers-only per API call; see x.ai dashboard","dashboard":"https://x.ai"}'
|
||||
}
|
||||
|
||||
probe_agy() {
|
||||
printf '%s' '{"status":"broken-api","note":"interactive /usage shows 100% (forum-verified bug); use Google Cloud Console","dashboard":"https://console.cloud.google.com/apis/api/generativelanguage.googleapis.com/quotas"}'
|
||||
}
|
||||
|
||||
probe_copilot() {
|
||||
# Try gh CLI graphQL — most variants don't expose Copilot billing publicly.
|
||||
# If we ever find an endpoint, drop it in here. For now: status marker.
|
||||
printf '%s' '{"status":"no-api","note":"see github.com/settings/billing → Copilot section","dashboard":"https://github.com/settings/billing"}'
|
||||
}
|
||||
|
||||
probe_kimi() {
|
||||
if [ -z "${MOONSHOT_API_KEY:-}" ]; then
|
||||
printf '%s' '{"status":"need-key","note":"set MOONSHOT_API_KEY in env to fetch live balance","dashboard":"https://platform.kimi.ai"}'
|
||||
return
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
printf '%s' '{"status":"no-curl","note":"curl required for live probe"}'
|
||||
return
|
||||
fi
|
||||
# v0.44 fix #3 (Gemini HIGH): sanitize MOONSHOT_API_KEY before formatting.
|
||||
# Was: token injected into a curl --config line via printf 'header = "...%s..."';
|
||||
# if the token contained a double-quote + newline + 'url = "attacker"',
|
||||
# curl would parse the injected config option and redirect the request.
|
||||
# Now: validate the key matches a known-safe charset; reject otherwise.
|
||||
case "$MOONSHOT_API_KEY" in
|
||||
*[!A-Za-z0-9_.\-]*)
|
||||
printf '%s' '{"status":"probe-failed","note":"MOONSHOT_API_KEY contains unsafe chars; expected [A-Za-z0-9_.-]"}'
|
||||
return
|
||||
;;
|
||||
esac
|
||||
local resp
|
||||
resp=$(printf 'header = "Authorization: Bearer %s"\n' "$MOONSHOT_API_KEY" \
|
||||
| curl -sS --max-time 5 --config - \
|
||||
"https://api.moonshot.ai/v1/users/me/balance" 2>/dev/null \
|
||||
|| echo '')
|
||||
if [ -z "$resp" ]; then
|
||||
printf '%s' '{"status":"probe-failed","note":"no response (network / wrong key)"}'
|
||||
return
|
||||
fi
|
||||
# v0.43-fix #2: tonumber? swallows parse errors (was: tonumber threw on
|
||||
# any non-numeric balance, emitted empty JSON, poisoned the assembler
|
||||
# --argjson → whole cache wiped).
|
||||
local avail
|
||||
avail=$(printf '%s' "$resp" | jq -r '.data.available_balance // empty' 2>/dev/null)
|
||||
if [ -z "$avail" ]; then
|
||||
printf '%s' '{"status":"probe-failed","note":"API returned non-balance response"}'
|
||||
return
|
||||
fi
|
||||
local cash voucher
|
||||
cash=$(printf '%s' "$resp" | jq -r '.data.cash_balance // 0' 2>/dev/null)
|
||||
voucher=$(printf '%s' "$resp" | jq -r '.data.voucher_balance // 0' 2>/dev/null)
|
||||
jq -n --arg s "live" --arg a "$avail" --arg c "$cash" --arg v "$voucher" \
|
||||
'{status:$s, available_balance_usd:($a|tonumber? // 0), cash_balance_usd:($c|tonumber? // 0), voucher_balance_usd:($v|tonumber? // 0), dashboard:"https://platform.kimi.ai"}'
|
||||
}
|
||||
|
||||
# --- assemble cache JSON ---------------------------------------------------
|
||||
# v0.43-fix #1: atomic stage-and-rename. Was: `jq > "$CACHE"` truncated the
|
||||
# cache BEFORE jq ran — a transient failure permanently wiped the cache.
|
||||
# Now: build in tmpfile, validate non-empty, then atomic mv. Preserves
|
||||
# last-known-good across probe failures.
|
||||
# v0.43-fix #2 (defense-in-depth): if any individual probe returns empty
|
||||
# string, substitute a status marker so --argjson never sees invalid JSON.
|
||||
|
||||
_safe_json() {
|
||||
local payload="$1"
|
||||
if [ -z "$payload" ]; then
|
||||
printf '%s' '{"status":"probe-empty","note":"probe returned empty result"}'
|
||||
return
|
||||
fi
|
||||
# Validate parses.
|
||||
if ! printf '%s' "$payload" | jq empty 2>/dev/null; then
|
||||
printf '%s' '{"status":"probe-invalid","note":"probe returned non-JSON"}'
|
||||
return
|
||||
fi
|
||||
printf '%s' "$payload"
|
||||
}
|
||||
|
||||
P_CLAUDE=$(_safe_json "$(probe_claude)")
|
||||
P_GROK=$(_safe_json "$(probe_grok)")
|
||||
P_AGY=$(_safe_json "$(probe_agy)")
|
||||
P_COPILOT=$(_safe_json "$(probe_copilot)")
|
||||
P_KIMI=$(_safe_json "$(probe_kimi)")
|
||||
|
||||
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
TMP=$(mktemp "${CACHE}.XXXXXX")
|
||||
if jq -n \
|
||||
--arg ts "$NOW" \
|
||||
--argjson claude "$P_CLAUDE" \
|
||||
--argjson grok "$P_GROK" \
|
||||
--argjson agy "$P_AGY" \
|
||||
--argjson copilot "$P_COPILOT" \
|
||||
--argjson kimi "$P_KIMI" \
|
||||
'{ts:$ts, claude:$claude, grok:$grok, agy:$agy, copilot:$copilot, kimi:$kimi}' \
|
||||
> "$TMP" 2>/dev/null \
|
||||
&& [ -s "$TMP" ]; then
|
||||
mv -f "$TMP" "$CACHE"
|
||||
else
|
||||
rm -f "$TMP" 2>/dev/null
|
||||
echo "kei-limits: cache refresh failed — keeping previous cache" >&2
|
||||
if [ ! -f "$CACHE" ]; then
|
||||
# v0.44 fix #9 (Claude MED): failure-fallback must carry the SAME schema
|
||||
# as the success cache (ts + 5 per-CLI keys). Was: emitted only {ts,
|
||||
# status} which broke pet's .kimi.available_balance_usd read and the
|
||||
# script's own per-CLI render loop. Now: full shape, all 5 marked
|
||||
# status="assembly-failed".
|
||||
jq -n '{ts:"",
|
||||
claude:{status:"assembly-failed",note:"see logs"},
|
||||
grok:{status:"assembly-failed",note:"see logs"},
|
||||
agy:{status:"assembly-failed",note:"see logs"},
|
||||
copilot:{status:"assembly-failed",note:"see logs"},
|
||||
kimi:{status:"assembly-failed",note:"see logs"}}' \
|
||||
> "$CACHE" 2>/dev/null \
|
||||
|| printf '%s\n' '{"ts":"","claude":{"status":"assembly-failed"},"grok":{"status":"assembly-failed"},"agy":{"status":"assembly-failed"},"copilot":{"status":"assembly-failed"},"kimi":{"status":"assembly-failed"}}' > "$CACHE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- output ----------------------------------------------------------------
|
||||
if [ "$JSON_OUT" = "1" ]; then
|
||||
cat "$CACHE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$QUIET" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
C0= CB= CG= CY= CR= CD=
|
||||
if [ -t 1 ]; then
|
||||
C0=$'\033[0m'
|
||||
CB=$'\033[1;38;5;39m'
|
||||
CG=$'\033[32m'
|
||||
CY=$'\033[33m'
|
||||
CR=$'\033[31m'
|
||||
CD=$'\033[2m'
|
||||
fi
|
||||
|
||||
format_one() {
|
||||
local label="$1" key="$2" data="$3"
|
||||
local status note
|
||||
status=$(printf '%s' "$data" | jq -r '.status')
|
||||
note=$(printf '%s' "$data" | jq -r '.note // ""')
|
||||
case "$status" in
|
||||
live)
|
||||
local avail
|
||||
avail=$(printf '%s' "$data" | jq -r '.available_balance_usd // empty')
|
||||
printf " ${CG}✓${C0} %-8s \$%-8s ${CD}live (Moonshot balance)${C0}\n" "$label" "$avail"
|
||||
;;
|
||||
no-api|need-key)
|
||||
printf " ${CY}?${C0} %-8s ${CD}%s${C0}\n" "$label" "$note"
|
||||
;;
|
||||
broken-api)
|
||||
printf " ${CR}✗${C0} %-8s ${CD}%s${C0}\n" "$label" "$note"
|
||||
;;
|
||||
*)
|
||||
printf " ${CY}?${C0} %-8s ${CD}%s${C0}\n" "$label" "$note"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cat <<EOF
|
||||
|
||||
${CB}╔════════════════════════════════════════════════════════════╗
|
||||
║ KeiSeiKit · CLI subscription limits ║
|
||||
╚════════════════════════════════════════════════════════════╝${C0}
|
||||
|
||||
EOF
|
||||
|
||||
CACHE_CONTENT=$(cat "$CACHE")
|
||||
for cli in claude grok agy copilot kimi; do
|
||||
data=$(printf '%s' "$CACHE_CONTENT" | jq -c ".$cli")
|
||||
format_one "$cli" "$cli" "$data"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "${CD}cached: $CACHE${C0}"
|
||||
echo "${CD}note: no CLI exposes session/weekly quota in a poll-friendly way.${C0}"
|
||||
echo "${CD} See dashboards via 'open <url>' from --json output.${C0}"
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# kei-mcp-wire-agy — TIER 3: advisory enforcement for Google Antigravity.
|
||||
#
|
||||
# Antigravity (Gemini-backed) has NO tool allowlist mechanism — only the
|
||||
# binary --dangerously-skip-permissions flag. We CANNOT disable its native
|
||||
# shell. Best we can do:
|
||||
# 1. Register kei-mcp via ~/.gemini/config/mcp_config.json
|
||||
# 2. Prompt the agent (via its system prompt) to prefer kei_bash
|
||||
# 3. Document honestly that this is advisory, not hard-enforced.
|
||||
|
||||
set -eu
|
||||
|
||||
CFG="$HOME/.gemini/config/mcp_config.json"
|
||||
KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
|
||||
[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)"
|
||||
|
||||
if [ -z "$KEI_MCP_BIN" ] || [ ! -x "$KEI_MCP_BIN" ]; then
|
||||
echo " agy: kei-mcp binary missing — build first: cargo build -p kei-mcp --release"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$CFG")"
|
||||
[ -f "$CFG" ] || echo '{}' > "$CFG"
|
||||
|
||||
desired=$(cat <<JSON
|
||||
{
|
||||
"mcpServers": {
|
||||
"kei-mcp": {
|
||||
"command": "$KEI_MCP_BIN"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
|
||||
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
|
||||
echo " agy: would merge into $CFG:"
|
||||
printf '%s\n' "$desired"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp=$(mktemp)
|
||||
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp"
|
||||
mv "$tmp" "$CFG"
|
||||
|
||||
cat <<EOF
|
||||
agy: kei-mcp registered → $CFG
|
||||
⚠ TIER 3 advisory: Antigravity has no way to disable native shell.
|
||||
Native bash remains reachable and ungated. The agent reads the
|
||||
system prompt (which mentions kei_bash) but may still use native.
|
||||
For patent-sensitive / production-PR work, use Claude or Grok.
|
||||
EOF
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# kei-mcp-wire-claude — verify Claude Code MCP wiring (TIER 1: already native).
|
||||
#
|
||||
# Claude Code reads MCP servers from ~/.claude/settings.json `mcpServers`
|
||||
# block. We don't strictly need kei-mcp here (Claude's native PreToolUse
|
||||
# hooks already enforce policy), but adding it gives Claude access to
|
||||
# `spawn_agent` for cross-CLI sub-agent dispatch.
|
||||
|
||||
set -eu
|
||||
|
||||
CFG="$HOME/.claude/settings.json"
|
||||
BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
|
||||
[ -f "$BIN" ] || BIN="$(command -v kei-mcp 2>/dev/null || true)"
|
||||
|
||||
if [ -z "$BIN" ] || [ ! -x "$BIN" ]; then
|
||||
echo " kei-mcp binary not found — build first: cargo build -p kei-mcp --release"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo " claude: native PreToolUse hooks already enforce policy chain (TIER 1)"
|
||||
echo " kei-mcp binary: $BIN"
|
||||
echo " (spawn_agent + kei_bash MCP tools available if added to"
|
||||
echo " $CFG mcpServers — optional for Claude.)"
|
||||
|
||||
# Optional: dump merge snippet
|
||||
if [ "${KEI_WIRE_CHECK:-0}" = "1" ] || [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ]; then
|
||||
cat <<EOF
|
||||
|
||||
Suggested merge into $CFG:
|
||||
{
|
||||
"mcpServers": {
|
||||
"kei-mcp": {
|
||||
"command": "$BIN",
|
||||
"env": { "CLAUDECODE": "1" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(CLAUDECODE=1 tells kei-mcp to skip its hook chain — your native hooks
|
||||
already fire on PreToolUse. Avoids double-enforcement.)
|
||||
EOF
|
||||
fi
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# kei-mcp-wire-copilot — TIER 2: MCP-wrapped enforcement for GitHub Copilot.
|
||||
#
|
||||
# Copilot CLI has NO hook system, BUT:
|
||||
# 1. Supports --excluded-tools='shell' to disable native shell.
|
||||
# 2. Has MCP server config at ~/.copilot/mcp-config.json.
|
||||
# So: register kei-mcp via MCP, and instruct user to launch Copilot with
|
||||
# --excluded-tools=shell so the agent can't use native bash and must use
|
||||
# our policy-gated kei_bash.
|
||||
|
||||
set -eu
|
||||
|
||||
CFG="$HOME/.copilot/mcp-config.json"
|
||||
KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
|
||||
[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)"
|
||||
|
||||
if [ -z "$KEI_MCP_BIN" ] || [ ! -x "$KEI_MCP_BIN" ]; then
|
||||
echo " copilot: kei-mcp binary missing — build first: cargo build -p kei-mcp --release"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$CFG")"
|
||||
[ -f "$CFG" ] || echo '{}' > "$CFG"
|
||||
|
||||
desired=$(cat <<JSON
|
||||
{
|
||||
"mcpServers": {
|
||||
"kei-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "$KEI_MCP_BIN"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
|
||||
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
|
||||
echo " copilot: would merge into $CFG:"
|
||||
printf '%s\n' "$desired"
|
||||
echo
|
||||
echo " copilot: launch flag to enforce: --excluded-tools='shell'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp=$(mktemp)
|
||||
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp"
|
||||
mv "$tmp" "$CFG"
|
||||
|
||||
echo " copilot: kei-mcp registered → $CFG"
|
||||
echo " copilot: to enforce, launch with: copilot --excluded-tools='shell'"
|
||||
echo " (this disables native shell; agent must use kei_bash via MCP)"
|
||||
echo " Consider adding an alias: alias copilot='copilot --excluded-tools=shell'"
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# kei-mcp-wire-grok — TIER 1: port KeiSeiKit hooks to Grok's PreToolUse pipeline.
|
||||
#
|
||||
# Grok CLI supports Claude-Code-compatible PreToolUse hooks via
|
||||
# ~/.grok/settings.json. Same JSON input contract → our existing
|
||||
# ~/.claude/hooks/*.sh scripts run unchanged.
|
||||
#
|
||||
# We register THREE hook entries (one per Bash-gating safety hook) plus
|
||||
# the kei-mcp MCP server so Grok can also call spawn_agent.
|
||||
#
|
||||
# Idempotent: jq-merge into existing settings.json; foreign entries survive.
|
||||
|
||||
set -eu
|
||||
|
||||
CFG="$HOME/.grok/settings.json"
|
||||
HOOKS_DIR="$HOME/.claude/hooks"
|
||||
KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
|
||||
[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)"
|
||||
|
||||
mkdir -p "$(dirname "$CFG")"
|
||||
[ -f "$CFG" ] || echo '{}' > "$CFG"
|
||||
|
||||
# Build the hook block — three Bash hooks + two Edit/Write hooks (same as
|
||||
# Claude's policy-chain.toml).
|
||||
desired=$(cat <<JSON
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOOKS_DIR/no-github-push.sh"}]},
|
||||
{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOOKS_DIR/safety-guard.sh"}]},
|
||||
{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOOKS_DIR/destructive-guard.sh"}]},
|
||||
{"matcher": "Edit", "hooks": [{"type": "command", "command": "$HOOKS_DIR/citation-verify.sh"}]},
|
||||
{"matcher": "Edit", "hooks": [{"type": "command", "command": "$HOOKS_DIR/numeric-claims-guard.sh"}]},
|
||||
{"matcher": "Write", "hooks": [{"type": "command", "command": "$HOOKS_DIR/citation-verify.sh"}]},
|
||||
{"matcher": "Write", "hooks": [{"type": "command", "command": "$HOOKS_DIR/numeric-claims-guard.sh"}]}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
|
||||
mcp_block=""
|
||||
if [ -n "$KEI_MCP_BIN" ] && [ -x "$KEI_MCP_BIN" ]; then
|
||||
mcp_block=$(cat <<JSON
|
||||
{
|
||||
"mcpServers": {
|
||||
"kei-mcp": {
|
||||
"command": "$KEI_MCP_BIN",
|
||||
"env": { "GROKCODE": "1" }
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
fi
|
||||
|
||||
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
|
||||
echo " grok: would merge into $CFG:"
|
||||
printf '%s\n' "$desired"
|
||||
[ -n "$mcp_block" ] && printf '%s\n' "$mcp_block"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Merge: existing | desired (desired wins on key conflict; arrays are
|
||||
# replaced, not appended — Grok PreToolUse semantics).
|
||||
tmp=$(mktemp)
|
||||
if [ -n "$mcp_block" ]; then
|
||||
jq -s '.[0] * .[1] * .[2]' "$CFG" <(printf '%s\n' "$desired") <(printf '%s\n' "$mcp_block") > "$tmp"
|
||||
else
|
||||
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp"
|
||||
fi
|
||||
mv "$tmp" "$CFG"
|
||||
|
||||
echo " grok: wired PreToolUse hooks → $CFG"
|
||||
echo " 5 hook entries (Bash×3 + Edit×2 + Write×2)"
|
||||
[ -n "$mcp_block" ] && echo " kei-mcp MCP server registered (with GROKCODE=1 guard)"
|
||||
echo " Same enforcement as Claude Code."
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# kei-mcp-wire-kimi — TIER 3: advisory enforcement for Moonshot Kimi.
|
||||
#
|
||||
# Kimi uses a confirmation-prompt model — no tool allowlist syntax, no
|
||||
# --excluded-tools flag. The user is prompted before every native tool
|
||||
# call (YOLO mode auto-approves). MCP server config at ~/.kimi/mcp.json.
|
||||
# Best we can do: register kei-mcp + prompt the agent to prefer kei_bash.
|
||||
|
||||
set -eu
|
||||
|
||||
CFG="$HOME/.kimi/mcp.json"
|
||||
KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
|
||||
[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)"
|
||||
|
||||
if [ -z "$KEI_MCP_BIN" ] || [ ! -x "$KEI_MCP_BIN" ]; then
|
||||
echo " kimi: kei-mcp binary missing — build first: cargo build -p kei-mcp --release"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$CFG")"
|
||||
[ -f "$CFG" ] || echo '{"mcpServers":{}}' > "$CFG"
|
||||
|
||||
desired=$(cat <<JSON
|
||||
{
|
||||
"mcpServers": {
|
||||
"kei-mcp": {
|
||||
"command": "$KEI_MCP_BIN",
|
||||
"transport": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
|
||||
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
|
||||
echo " kimi: would merge into $CFG:"
|
||||
printf '%s\n' "$desired"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp=$(mktemp)
|
||||
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp"
|
||||
mv "$tmp" "$CFG"
|
||||
|
||||
cat <<EOF
|
||||
kimi: kei-mcp registered → $CFG
|
||||
Alternative via Kimi CLI: kimi mcp add kei-mcp --transport stdio \\
|
||||
--command "$KEI_MCP_BIN"
|
||||
⚠ TIER 3 advisory: Kimi has only confirmation prompts, no allowlist.
|
||||
Native shell remains reachable. Keep YOLO mode OFF for safety.
|
||||
For patent-sensitive work, use Claude or Grok as orchestrator.
|
||||
EOF
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# kei-mcp-wire — orchestrator for cross-CLI MCP enforcement setup.
|
||||
#
|
||||
# Phase C cube — wires kei-mcp (with kei_bash/kei_edit/kei_write tools) into
|
||||
# each installed LLM CLI's MCP config, plus per-CLI tool-restriction config
|
||||
# where the CLI supports it.
|
||||
#
|
||||
# Usage:
|
||||
# kei mcp-wire # detect installed CLIs + wire each
|
||||
# kei mcp-wire <cli> # wire one: claude/grok/copilot/agy/kimi
|
||||
# kei mcp-wire --check # diff: current vs target (no writes)
|
||||
# kei mcp-wire --dry-run # preview changes without applying
|
||||
# kei mcp-wire --list # show enforcement tier per CLI
|
||||
#
|
||||
# Enforcement tiers (3-tier honesty model):
|
||||
# TIER 1 — full native: claude (existing hooks), grok (ports our hooks
|
||||
# to ~/.grok/settings.json — same JSON shape)
|
||||
# TIER 2 — MCP-wrapped: copilot (disable native shell + force kei_bash)
|
||||
# TIER 3 — advisory: agy + kimi (cannot disable native shell;
|
||||
# MCP available but enforcement is prompt-only)
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DRY_RUN=0
|
||||
CHECK=0
|
||||
LIST=0
|
||||
TARGET=""
|
||||
|
||||
usage() { sed -n '2,17p' "$0" | sed 's|^# \{0,1\}||'; }
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--check) CHECK=1 ;;
|
||||
--list) LIST=1 ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) TARGET="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
export KEI_WIRE_DRY_RUN="$DRY_RUN"
|
||||
export KEI_WIRE_CHECK="$CHECK"
|
||||
|
||||
declare -A TIERS=(
|
||||
[claude]="TIER 1: full native"
|
||||
[grok]="TIER 1: full native (ports our hooks)"
|
||||
[copilot]="TIER 2: MCP-wrapped (disable native shell)"
|
||||
[agy]="TIER 3: advisory (no native-shell disable)"
|
||||
[kimi]="TIER 3: advisory (confirmation model only)"
|
||||
)
|
||||
|
||||
backend_bin() {
|
||||
case "$1" in
|
||||
claude) echo "claude" ;;
|
||||
grok) echo "grok" ;;
|
||||
agy|antigravity) echo "agy" ;;
|
||||
copilot) echo "copilot" ;;
|
||||
kimi) echo "kimi" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ "$LIST" = "1" ]; then
|
||||
echo "Cross-CLI enforcement tiers:"
|
||||
for cli in claude grok copilot agy kimi; do
|
||||
bin=$(backend_bin "$cli")
|
||||
if command -v "$bin" >/dev/null 2>&1; then
|
||||
mark="✓"
|
||||
else
|
||||
mark="✗"
|
||||
fi
|
||||
printf " %s %-8s %s\n" "$mark" "$cli" "${TIERS[$cli]}"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
wire_one() {
|
||||
local cli="$1" wire_script="$SCRIPT_DIR/kei-mcp-wire-$cli.sh"
|
||||
if [ ! -x "$wire_script" ]; then
|
||||
echo "[kei-mcp-wire] no wire script for: $cli (expected $wire_script)" >&2
|
||||
return 2
|
||||
fi
|
||||
local bin
|
||||
bin=$(backend_bin "$cli") || { echo "unknown cli: $cli" >&2; return 2; }
|
||||
if ! command -v "$bin" >/dev/null 2>&1; then
|
||||
echo "[kei-mcp-wire] $cli not installed (skipping)"
|
||||
return 0
|
||||
fi
|
||||
echo
|
||||
echo "──── $cli (${TIERS[$cli]}) ────"
|
||||
"$wire_script"
|
||||
}
|
||||
|
||||
if [ -n "$TARGET" ]; then
|
||||
wire_one "$TARGET"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# No target → wire all installed CLIs.
|
||||
echo "kei-mcp-wire: detecting installed CLIs..."
|
||||
for cli in claude grok copilot agy kimi; do
|
||||
wire_one "$cli"
|
||||
done
|
||||
echo
|
||||
echo "done. See \`kei mcp-wire --list\` for per-CLI enforcement tier."
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# kei-onboard — post-install wizard.
|
||||
#
|
||||
# Runs after install.sh / bootstrap.sh to guide the user through:
|
||||
# Step 1: pick the primary LLM orchestrator (default for `kei` no-args)
|
||||
# Step 2: wire kei-mcp into the chosen CLI (cross-CLI policy + spawn_agent)
|
||||
# Step 3: optional MOONSHOT_API_KEY hint for kei limits
|
||||
# Step 4: quick health check
|
||||
#
|
||||
# Idempotent — safe to re-run anytime via `kei onboard`.
|
||||
# Honors TTY gate: non-interactive runs print summary + exit, no prompts.
|
||||
|
||||
set -eu
|
||||
|
||||
KEI_PRIMARY_CFG="${KEI_PRIMARY_CFG:-$HOME/.claude/config/primary.toml}"
|
||||
PICK_SH="$HOME/.claude/scripts/kei-pick.sh"
|
||||
WIRE_SH="$HOME/.claude/scripts/kei-mcp-wire.sh"
|
||||
|
||||
# Colors only if stdout is a TTY (TTY-INTERACTIVITY-GATE: -t 1 for color is OK).
|
||||
C0= CB= CC= CG= CD= CR=
|
||||
if [ -t 1 ]; then
|
||||
C0=$'\033[0m'
|
||||
CB=$'\033[1;38;5;39m' # blue
|
||||
CC=$'\033[1;38;5;220m' # gold
|
||||
CG=$'\033[32m' # green
|
||||
CR=$'\033[31m' # red
|
||||
CD=$'\033[2m' # dim
|
||||
fi
|
||||
|
||||
# Non-interactive (no stdin TTY): print summary + exit.
|
||||
# Per tty-interactivity-gate.md: -t 0 not -t 1.
|
||||
if [ ! -t 0 ]; then
|
||||
cat <<EOF
|
||||
|
||||
${CB}KeiSeiKit · onboarding${C0} (non-interactive — wizard skipped)
|
||||
|
||||
Next manual steps:
|
||||
${CC}kei onboard${C0} run this wizard interactively
|
||||
${CC}kei pick${C0} pick primary LLM CLI
|
||||
${CC}kei mcp-wire${C0} wire kei-mcp into your CLIs
|
||||
${CC}kei limits${C0} check subscription quotas (honest report)
|
||||
${CC}kei-doctor${C0} substrate health diagnostic
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Banner
|
||||
cat <<EOF
|
||||
|
||||
${CB}╔═══════════════════════════════════════════════════════════════════╗
|
||||
║ KeiSeiKit · post-install onboarding ║
|
||||
╚═══════════════════════════════════════════════════════════════════╝${C0}
|
||||
|
||||
The install put 38 agents, 54 hooks, and 60+ Rust primitives in place.
|
||||
Now let's wire up the LLM CLIs you'll actually use.
|
||||
|
||||
EOF
|
||||
|
||||
# ── Step 1: pick primary ───────────────────────────────────────────
|
||||
echo "${CB}── Step 1/4 — Pick your primary LLM orchestrator ──${C0}"
|
||||
echo
|
||||
echo "When you run ${CC}kei${C0} (no args) it launches your primary CLI."
|
||||
echo "Each agent's manifest can also declare a preferred provider (DNA)."
|
||||
echo
|
||||
|
||||
declare -a BACKENDS=(claude grok agy copilot kimi)
|
||||
declare -A LABELS=(
|
||||
[claude]="Claude Code (Anthropic, full hook enforcement)"
|
||||
[grok]="Grok (xAI, native --agent flag)"
|
||||
[agy]="Antigravity (Google Gemini)"
|
||||
[copilot]="GitHub Copilot (Microsoft, MCP-wrapped)"
|
||||
[kimi]="Kimi (Moonshot, TUI-primary)"
|
||||
)
|
||||
|
||||
i=1
|
||||
for b in "${BACKENDS[@]}"; do
|
||||
if command -v "$b" >/dev/null 2>&1; then
|
||||
mark="${CG}✓${C0}"
|
||||
else
|
||||
mark="${CR}✗${C0} ${CD}(not installed)${C0}"
|
||||
fi
|
||||
printf " ${CB}%d${C0}) %s %-20s %s\n" "$i" "$mark" "$b" "${LABELS[$b]}"
|
||||
i=$((i+1))
|
||||
done
|
||||
echo " ${CB}s${C0}) skip — keep current primary (claude default)"
|
||||
echo
|
||||
|
||||
current=""
|
||||
[ -f "$KEI_PRIMARY_CFG" ] && current=$(awk -F'=' '/^provider/ {gsub(/[" ]/, "", $2); print $2; exit}' "$KEI_PRIMARY_CFG")
|
||||
printf "Current primary: ${CC}%s${C0}\n" "${current:-claude (default)}"
|
||||
printf "Pick [1-${#BACKENDS[@]}/s, default=s]: "
|
||||
read -r choice
|
||||
choice="${choice:-s}"
|
||||
|
||||
primary_set=""
|
||||
case "$choice" in
|
||||
s|S|"")
|
||||
echo " ${CD}— keeping ${current:-claude}${C0}"
|
||||
primary_set="${current:-claude}"
|
||||
;;
|
||||
[1-9])
|
||||
idx=$((choice-1))
|
||||
if [ $idx -ge ${#BACKENDS[@]} ] || [ $idx -lt 0 ]; then
|
||||
echo " ${CR}invalid; keeping ${current:-claude}${C0}"
|
||||
primary_set="${current:-claude}"
|
||||
else
|
||||
new="${BACKENDS[$idx]}"
|
||||
mkdir -p "$(dirname "$KEI_PRIMARY_CFG")"
|
||||
printf '# kei primary — written %s by onboarding\nprovider = "%s"\n' \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$new" > "$KEI_PRIMARY_CFG"
|
||||
echo " ${CG}✓${C0} primary set: ${CC}${new}${C0} → $KEI_PRIMARY_CFG"
|
||||
primary_set="$new"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo " ${CR}invalid; keeping ${current:-claude}${C0}"
|
||||
primary_set="${current:-claude}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Step 2: mcp-wire ───────────────────────────────────────────────
|
||||
echo
|
||||
echo "${CB}── Step 2/4 — Wire kei-mcp into installed CLIs ──${C0}"
|
||||
echo
|
||||
echo "kei-mcp exposes ${CC}spawn_agent${C0} + ${CC}kei_bash/kei_edit/kei_write${C0} (with"
|
||||
echo "policy chain) to any MCP-capable CLI. Enables cross-CLI agent invocation"
|
||||
echo "AND hook enforcement on non-Claude backends."
|
||||
echo
|
||||
printf "Run ${CC}kei mcp-wire${C0} now (writes to ~/.grok/, ~/.copilot/, etc.)? [Y/n]: "
|
||||
read -r wire_ans
|
||||
wire_ans="${wire_ans:-Y}"
|
||||
case "$wire_ans" in
|
||||
y|Y|yes)
|
||||
if [ -x "$WIRE_SH" ]; then
|
||||
"$WIRE_SH"
|
||||
else
|
||||
echo " ${CR}— $WIRE_SH not found; skip${C0}"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo " ${CD}— skipped. Run later: ${CC}kei mcp-wire${C0}${CD}${C0}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Step 3: MOONSHOT key hint ──────────────────────────────────────
|
||||
echo
|
||||
echo "${CB}── Step 3/4 — Live subscription limits (optional) ──${C0}"
|
||||
echo
|
||||
echo "${CC}kei limits${C0} probes each CLI's subscription quota. Research found that"
|
||||
echo "only Kimi exposes a public API; the others are dashboard-only."
|
||||
echo
|
||||
if [ -n "${MOONSHOT_API_KEY:-}" ]; then
|
||||
echo " ${CG}✓${C0} MOONSHOT_API_KEY is set — Kimi balance probing enabled"
|
||||
else
|
||||
cat <<EOF
|
||||
${CD}Optional: set ${CC}MOONSHOT_API_KEY${CD} in ${CC}~/.claude/secrets/.env${CD} to enable
|
||||
Kimi balance polling. Other CLIs: see dashboards via ${CC}kei limits${CD}.${C0}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Step 4: health check ───────────────────────────────────────────
|
||||
echo
|
||||
echo "${CB}── Step 4/4 — Health check ──${C0}"
|
||||
echo
|
||||
if command -v kei-doctor >/dev/null 2>&1; then
|
||||
kei-doctor 2>&1 | head -20 || true
|
||||
else
|
||||
echo " ${CD}— kei-doctor not on PATH yet. Open new shell + run: ${CC}kei-doctor${C0}"
|
||||
fi
|
||||
|
||||
# ── Done ───────────────────────────────────────────────────────────
|
||||
cat <<EOF
|
||||
|
||||
${CB}╔═══════════════════════════════════════════════════════════════════╗
|
||||
║ Onboarding complete. ║
|
||||
╚═══════════════════════════════════════════════════════════════════╝${C0}
|
||||
|
||||
Quick-start:
|
||||
${CC}kei${C0} launch ${primary_set} (your primary)
|
||||
${CC}kei agent critic "..."${C0} invoke an agent (DNA → primary)
|
||||
${CC}kei agent --on=grok critic "..."${C0} invoke on a specific backend
|
||||
${CC}kei mcp-wire --list${C0} show enforcement tiers per CLI
|
||||
${CC}kei limits${C0} quota report (where APIs exist)
|
||||
${CC}kei pick${C0} re-pick primary anytime
|
||||
${CC}kei configure${C0} re-pick hook packs / stack profile
|
||||
|
||||
Docs: ${CD}~/.local/share/keisei/docs/encyclopedia/${C0}
|
||||
Logs: ${CD}~/.keisei-install.log${C0}
|
||||
|
||||
EOF
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -72,91 +72,40 @@ _elapsed() {
|
|||
else printf '%dh%dm' $(( s / 3600 )) $(( (s % 3600) / 60 )); fi
|
||||
}
|
||||
|
||||
# ── running sub-agents (count only — compact view, no per-agent list) ────────
|
||||
# Counts younger-than-2h .task-*.start markers across ALL parallel sessions.
|
||||
# v0.40: dropped per-agent emoji+name list to keep status line readable when
|
||||
# many parallel sessions/agents fire. Per-agent detail moved to `kei status`
|
||||
# (see TODO) — pet stays as a single counter.
|
||||
n_agents=0
|
||||
# ── running sub-agents (from task-timer's .task-*.start) ─────────────────────
|
||||
agents=""; n_agents=0
|
||||
if [ -d "$TM_DIR" ]; then
|
||||
for f in "$TM_DIR"/.task-*.start; do
|
||||
[ -f "$f" ] || continue
|
||||
typ="$(jq -r '.type // "agent"' "$f" 2>/dev/null)"
|
||||
st="$(jq -r '.start_epoch // empty' "$f" 2>/dev/null)"
|
||||
[ -z "$st" ] && continue
|
||||
age=$(( now - st ))
|
||||
[ "$age" -gt 7200 ] && continue
|
||||
[ "$age" -gt 7200 ] && continue # ignore stale (kit removes on done)
|
||||
short="$(printf '%s' "$typ" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9].*$//' | cut -c1-12)"
|
||||
agents+=" $(_agent_emoji "$typ")${short}·$(_elapsed "$age")"
|
||||
n_agents=$((n_agents+1))
|
||||
done
|
||||
fi
|
||||
|
||||
# ── today's aggregates (across ALL sessions) ─────────────────────────────────
|
||||
# Tokens + cost from agent-events.jsonl; sessions from distinct parent_id of
|
||||
# today's agent_spawn events.
|
||||
today_tok=0; today_cost=0; today_sess=0
|
||||
# ── agent token / cost spend today (from agent-events.jsonl) ─────────────────
|
||||
spend=""
|
||||
if [ -f "$EVENTS" ]; then
|
||||
today="$(date -u +%Y-%m-%d)"
|
||||
# Single awk pass: count tokens, cost, distinct parent_id.
|
||||
read -r today_tok today_cost today_sess < <(awk -v d="$today" '
|
||||
read -r tot_tok tot_cost < <(awk -v d="$today" '
|
||||
index($0,d)>0 {
|
||||
if (match($0,/total_tokens[^0-9]*[0-9]+/)) { s=substr($0,RSTART,RLENGTH); gsub(/[^0-9]/,"",s); T+=s }
|
||||
if (match($0,/"cost_usd"[: ]*[0-9.]+/)) { s=substr($0,RSTART,RLENGTH); gsub(/[^0-9.]/,"",s); C+=s }
|
||||
if (match($0,/"parent_id"[: ]*"[^"]+"/)) { s=substr($0,RSTART,RLENGTH); gsub(/.*"parent_id"[: ]*"|".*/,"",s); seen[s]=1 }
|
||||
} END {
|
||||
n=0; for (k in seen) n++
|
||||
printf "%d %.4f %d", T+0, C+0, n
|
||||
}' "$EVENTS" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Format tokens compactly: 1234567 → 1.2M / 5400 → 5k / 999 → 999
|
||||
_short_tok() {
|
||||
local n=${1:-0}
|
||||
if [ "$n" -ge 1000000 ]; then awk -v n="$n" 'BEGIN{printf "%.1fM", n/1000000}'
|
||||
elif [ "$n" -ge 1000 ]; then awk -v n="$n" 'BEGIN{printf "%dk", n/1000}'
|
||||
else printf '%d' "$n"
|
||||
fi
|
||||
}
|
||||
|
||||
global=""
|
||||
[ "${today_sess:-0}" -gt 0 ] 2>/dev/null && global+="💬${today_sess} "
|
||||
[ "${today_tok:-0}" -gt 0 ] 2>/dev/null && global+="🌍$(_short_tok "$today_tok") "
|
||||
[ "${n_agents:-0}" -gt 0 ] 2>/dev/null && global+="🤖${n_agents} "
|
||||
spend=""
|
||||
if [ "${today_cost:-0}" != "0.0000" ] && [ -n "${today_cost:-}" ]; then
|
||||
spend="💰\$$(printf '%.2f' "$today_cost" 2>/dev/null)"
|
||||
fi
|
||||
[ -n "$spend" ] && global+="${spend} "
|
||||
global="${global% }"
|
||||
|
||||
# v0.43: CLI subscription limits (best-effort).
|
||||
# Pet does NOT poll — reads cache only. Cache populated by `kei limits`.
|
||||
# Reality: 4 of 5 CLIs have no programmatic limit API (see research). Pet
|
||||
# shows only what's actually available + how stale the cache is.
|
||||
limits_cache="${HOME}/.claude/pet/limits-cache.json"
|
||||
limits=""
|
||||
if [ -f "$limits_cache" ]; then
|
||||
# Cache age in seconds.
|
||||
cache_ts=$(jq -r '.ts // empty' "$limits_cache" 2>/dev/null)
|
||||
if [ -n "$cache_ts" ]; then
|
||||
# Convert ISO8601 to epoch (macOS + Linux compatible).
|
||||
cache_epoch=$(
|
||||
date -j -u -f "%Y-%m-%dT%H:%M:%SZ" "$cache_ts" "+%s" 2>/dev/null \
|
||||
|| date -u -d "$cache_ts" "+%s" 2>/dev/null \
|
||||
|| echo 0
|
||||
)
|
||||
cache_age=$(( now - cache_epoch ))
|
||||
# Kimi balance (only CLI with live API). Show $X.XX if available.
|
||||
kimi_avail=$(jq -r '.kimi | select(.status=="live") | .available_balance_usd' "$limits_cache" 2>/dev/null)
|
||||
if [ -n "$kimi_avail" ] && [ "$kimi_avail" != "null" ]; then
|
||||
limits+="K:\$$(printf '%.2f' "$kimi_avail" 2>/dev/null) "
|
||||
fi
|
||||
# Stale marker if older than 1h.
|
||||
if [ "$cache_age" -gt 3600 ] 2>/dev/null && [ -n "$limits" ]; then
|
||||
stale_min=$((cache_age / 60))
|
||||
limits="${limits% }${dim}(${stale_min}m old)${reset} "
|
||||
fi
|
||||
t=0; c=0
|
||||
if (match($0,/total_tokens[^0-9]*[0-9]+/)) { s=substr($0,RSTART,RLENGTH); gsub(/[^0-9]/,"",s); t=s }
|
||||
if (match($0,/"cost_usd"[: ]*[0-9.]+/)) { s=substr($0,RSTART,RLENGTH); gsub(/[^0-9.]/,"",s); c=s }
|
||||
T+=t; C+=c
|
||||
} END { printf "%d %.4f", T+0, C+0 }' "$EVENTS" 2>/dev/null)
|
||||
# agent COST only (💰) — session tokens are shown separately as 🪙 above,
|
||||
# so we don't repeat a token count here. Cost is non-null only when the
|
||||
# sub-agent payload carried a model.
|
||||
if [ "${tot_cost:-0}" != "0.0000" ] && [ -n "${tot_cost:-}" ]; then
|
||||
spend=" 💰\$$(printf '%.2f' "$tot_cost" 2>/dev/null)"
|
||||
fi
|
||||
fi
|
||||
limits="${limits% }"
|
||||
|
||||
# ── THIS session: tokens + context% (from statusLine stdin) ─────────────────
|
||||
sess=""
|
||||
|
|
@ -201,10 +150,9 @@ EWIN="${HOME}/.claude/memory/error-window.txt"
|
|||
proj="${PWD##*/}"; [ -z "$proj" ] && proj="~"
|
||||
|
||||
out=""
|
||||
[ -n "$sess" ] && out+="${sess} "
|
||||
[ -n "$global" ] && out+="${dim}${global}${reset} "
|
||||
[ -n "$limits" ] && out+="${dim}${limits}${reset} "
|
||||
[ -n "$plan" ] && out+="${plan} "
|
||||
[ -n "$sess" ] && out+="${sess} "
|
||||
[ -n "${agents// }" ] && out+="${agents# }${spend} "
|
||||
[ -n "$plan" ] && out+="${plan} "
|
||||
out+="${color}${face}${reset}"
|
||||
[ -n "$message" ] && out+=" ${dim}${message}${reset}"
|
||||
out+="${stats}"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
#
|
||||
# Env / args:
|
||||
# KEISEI_ROOT install dir (default: $HOME/.local/share/keisei)
|
||||
# KEISEI_REPO git URL (default: https://github.com/KeiSeiLab/KeiSeiKit-1.0.git)
|
||||
# KEISEI_REPO git URL (default: https://keigit.com/keisei/KeiSeiKit-1.0.git)
|
||||
# KEISEI_REF branch/tag/sha (default: main)
|
||||
# --profile=NAME passed through to ./bootstrap.sh
|
||||
# --yes passed through to ./bootstrap.sh
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
set -euo pipefail
|
||||
|
||||
KEISEI_ROOT="${KEISEI_ROOT:-$HOME/.local/share/keisei}"
|
||||
KEISEI_REPO="${KEISEI_REPO:-https://github.com/KeiSeiLab/KeiSeiKit-1.0.git}"
|
||||
KEISEI_REPO="${KEISEI_REPO:-https://keigit.com/keisei/KeiSeiKit-1.0.git}"
|
||||
KEISEI_REF="${KEISEI_REF:-main}"
|
||||
|
||||
PASS_THROUGH=()
|
||||
|
|
|
|||
Loading…
Reference in a new issue