Compare commits
No commits in common. "main" and "v0.43.0" have entirely different histories.
9 changed files with 107 additions and 568 deletions
|
|
@ -60,12 +60,8 @@ 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.
|
||||
/// Hard cap on how long a single hook chain + action may take. Matches the
|
||||
/// timeout in `handlers::tools::ATOM_TIMEOUT_SECS` for consistency.
|
||||
const SAFE_TOOL_TIMEOUT_SECS: u64 = 60;
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
|
|
@ -152,16 +148,9 @@ async fn handle_bash(args: &Value) -> Result<String, String> {
|
|||
.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
|
||||
}
|
||||
"tool_input": { "command": command }
|
||||
});
|
||||
run_chain("bash", &hook_input).await?;
|
||||
|
||||
|
|
@ -174,14 +163,9 @@ async fn handle_bash(args: &Value) -> Result<String, String> {
|
|||
.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).
|
||||
// v0.41 fix #5 (Gemini MED): 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();
|
||||
|
|
@ -212,42 +196,15 @@ async fn handle_bash(args: &Value) -> Result<String, String> {
|
|||
}
|
||||
|
||||
// v0.41 fix #5: process-group helpers (Unix-only; no-op on other platforms).
|
||||
// tokio::process::Command::process_group is available on Unix without
|
||||
// requiring the std::os::unix::process::CommandExt trait import.
|
||||
#[cfg(unix)]
|
||||
fn set_process_group(cmd: &mut Command) {
|
||||
cmd.process_group(0);
|
||||
cmd.process_group(0); // 0 = new session leader for this child
|
||||
}
|
||||
#[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.
|
||||
|
|
@ -267,12 +224,7 @@ async fn handle_edit(args: &Value) -> Result<String, 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());
|
||||
}
|
||||
|
||||
// v0.41 fix #2: path-traversal guard
|
||||
let safe_path = validate_path(file_path)?;
|
||||
|
||||
let hook_input = json!({
|
||||
|
|
@ -285,12 +237,16 @@ async fn handle_edit(args: &Value) -> Result<String, 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
|
||||
// v0.41 fix #4: tokio::fs (async)
|
||||
let contents = fs::read_to_string(&safe_path).await
|
||||
.map_err(|e| format!("read {}: {e}", safe_path.display()))?;
|
||||
if !contents.contains(old_string) {
|
||||
return Err(format!("kei_edit: old_string not found in {}", safe_path.display()));
|
||||
}
|
||||
let updated = contents.replacen(old_string, new_string, 1);
|
||||
fs::write(&safe_path, &updated).await
|
||||
.map_err(|e| format!("write {}: {e}", safe_path.display()))?;
|
||||
Ok(format!("edited {} ({} bytes)", safe_path.display(), updated.len()))
|
||||
}
|
||||
|
||||
async fn handle_write(args: &Value) -> Result<String, String> {
|
||||
|
|
@ -299,6 +255,7 @@ async fn handle_write(args: &Value) -> Result<String, String> {
|
|||
let content = args.get("content").and_then(Value::as_str)
|
||||
.ok_or_else(|| missing_arg("kei_write", "content"))?;
|
||||
|
||||
// v0.41 fix #2: path-traversal guard
|
||||
let safe_path = validate_path(file_path)?;
|
||||
|
||||
let hook_input = json!({
|
||||
|
|
@ -313,93 +270,9 @@ async fn handle_write(args: &Value) -> Result<String, String> {
|
|||
.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()))
|
||||
fs::write(&safe_path, content).await
|
||||
.map_err(|e| format!("write {}: {e}", safe_path.display()))?;
|
||||
Ok(format!("wrote {} ({} bytes)", safe_path.display(), content.len()))
|
||||
}
|
||||
|
||||
/// Path-traversal + symlink + denylist guard.
|
||||
|
|
@ -427,16 +300,36 @@ fn validate_path(p: &str) -> Result<PathBuf, String> {
|
|||
}
|
||||
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)?;
|
||||
// 2. Build a canonical path. Prefer canonicalizing the FULL path (resolves
|
||||
// symlinks at the leaf, fixing v0.41 CRITICAL bypass). For files that
|
||||
// don't exist yet (kei_write new file), canonicalize the parent and
|
||||
// join the leaf — but then explicitly check the leaf isn't a symlink
|
||||
// via symlink_metadata before writing.
|
||||
let canonical = if path.exists() {
|
||||
// File exists — canonicalize full path, including resolving any leaf
|
||||
// symlink to its real target. The denylist/roots check below then
|
||||
// sees the REAL destination, not the symlink name.
|
||||
path.canonicalize()
|
||||
.map_err(|e| format!("file_path: canonicalize {}: {e}", path.display()))?
|
||||
} else if let Some(parent) = path.parent() {
|
||||
if parent.as_os_str().is_empty() || parent == Path::new("") {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| format!("file_path: cwd unavailable: {e}"))?
|
||||
.join(path.file_name().unwrap_or_default())
|
||||
} else if parent.exists() {
|
||||
parent.canonicalize()
|
||||
.map_err(|e| format!("file_path: canonicalize {}: {e}", parent.display()))?
|
||||
.join(path.file_name().unwrap_or_default())
|
||||
} else if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| format!("file_path: cwd unavailable: {e}"))?
|
||||
.join(path)
|
||||
}
|
||||
} else {
|
||||
return Err(format!("file_path: invalid {p}"));
|
||||
};
|
||||
|
||||
// 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.
|
||||
|
|
@ -449,49 +342,29 @@ fn validate_path(p: &str) -> Result<PathBuf, String> {
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 4. Reject system + substrate-control + credential paths.
|
||||
let denylist = [
|
||||
"/etc/", "/usr/", "/System/", "/var/db/", "/var/log/", "/var/root/",
|
||||
"/private/etc/", "/private/var/db/", "/private/var/log/", "/private/var/root/",
|
||||
"/etc/", "/usr/", "/System/", "/var/", "/private/etc/", "/private/var/",
|
||||
"/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") {
|
||||
// v0.42 fix #2 extended denylist — these targets enable self-attack
|
||||
// (overwrite the substrate or shell init for RCE on next session).
|
||||
let dir_secrets = [
|
||||
".ssh/", ".aws/", ".gnupg/", ".config/gcloud/", ".cargo/credentials",
|
||||
".npmrc", ".docker/config.json", ".kube/",
|
||||
".claude/", ".grok/", ".gemini/", ".copilot/", ".kimi/",
|
||||
".claude/", // our own substrate: hooks, settings, agents
|
||||
".grok/", // sibling CLI's settings
|
||||
".gemini/", // antigravity settings
|
||||
".copilot/", // copilot config
|
||||
".kimi/", // kimi config
|
||||
];
|
||||
for sd in dir_secrets {
|
||||
let full = format!("{home}/{sd}");
|
||||
|
|
@ -499,6 +372,7 @@ fn validate_path(p: &str) -> Result<PathBuf, String> {
|
|||
return Err(format!("file_path: denied (secret/substrate dir): {canon_str}"));
|
||||
}
|
||||
}
|
||||
// Exact shell-init files (overwriting → RCE on next shell start).
|
||||
let init_files = [
|
||||
".zshrc", ".bashrc", ".profile", ".bash_profile", ".zprofile",
|
||||
".zshenv", ".bash_login", ".inputrc", ".gitconfig",
|
||||
|
|
@ -512,71 +386,31 @@ fn validate_path(p: &str) -> Result<PathBuf, String> {
|
|||
}
|
||||
}
|
||||
|
||||
// 5. Enforce allowed-root containment.
|
||||
let roots = allowed_roots();
|
||||
if !roots.is_empty() {
|
||||
let ok = roots.iter().any(|r| canon_str.starts_with(r));
|
||||
if !ok {
|
||||
return Err(format!(
|
||||
"file_path: outside allowed roots {roots:?}: {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();
|
||||
return v.split(':').filter(|s| !s.is_empty()).map(String::from).collect();
|
||||
}
|
||||
// v0.42 fix #2 (Claude+Gemini HIGH): default to $PWD ONLY. Was: $PWD +
|
||||
// $HOME blanket — too permissive, agent could overwrite ~/.claude/hooks/
|
||||
// or ~/.zshrc and self-neuter the safety layer. Operators who need
|
||||
// broader access opt in via KEI_ALLOWED_ROOTS=":" -separated abs paths.
|
||||
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.push(format!("{}/", cwd.display()));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
|
@ -638,9 +472,9 @@ async fn run_chain(tool: &str, hook_input: &Value) -> Result<(), String> {
|
|||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
// v0.42 fix #5: put hook child in its own process group so timeout
|
||||
// can killpg the whole tree (was: kill_on_drop = immediate child only).
|
||||
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()
|
||||
|
|
|
|||
7
bin/kei
7
bin/kei
|
|
@ -22,7 +22,6 @@
|
|||
# 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)
|
||||
#
|
||||
|
|
@ -73,10 +72,6 @@ case "${1:-}" in
|
|||
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) -------
|
||||
|
|
@ -235,7 +230,7 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█
|
|||
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
|
||||
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
|
||||
|
||||
${C2} KeiSeiKit · substrate v0.45${C0}
|
||||
${C2} KeiSeiKit · substrate v0.42${C0}
|
||||
${C3} ─────────────────────────────────────${C0}
|
||||
primary CLI : ${CV}${PRIMARY}${C0}
|
||||
profile : ${CV}${p}${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."
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"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",
|
||||
"version": "0.42.0",
|
||||
"homepage": "https://keisei.app",
|
||||
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
|
||||
"author": {
|
||||
|
|
|
|||
|
|
@ -70,17 +70,9 @@ probe_kimi() {
|
|||
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
|
||||
# v0.43-fix #3: feed the bearer token via stdin (--config -), NOT as
|
||||
# a curl argv. argv is visible to `ps`/`/proc/<pid>/cmdline` for any
|
||||
# local user. Audit found this on critic@claude.
|
||||
local resp
|
||||
resp=$(printf 'header = "Authorization: Bearer %s"\n' "$MOONSHOT_API_KEY" \
|
||||
| curl -sS --max-time 5 --config - \
|
||||
|
|
@ -151,19 +143,9 @@ 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"
|
||||
# No prior cache + assembly failed: write a minimal marker so consumers
|
||||
# don't see a missing file as their failure mode.
|
||||
printf '%s\n' '{"ts":"","status":"assembly-failed"}' > "$CACHE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue