Compare commits
43 commits
fix/instal
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bc40e8e69 | |||
| 3b54f0b5e0 | |||
| 424a6ced00 | |||
| a9e01a6b17 | |||
| 633ee4aeeb | |||
| 65d17007c3 | |||
| 8086bec486 | |||
| 75325aaf03 | |||
| ad6ccd2beb | |||
| 087be08e66 | |||
| 596e0b20a1 | |||
| 4e5e6bd2c0 | |||
| 3fec43ea7e | |||
| e4980f6ad7 | |||
| 3be9a8bf71 | |||
| 518d95df80 | |||
| abae256c1d | |||
| 2ffb3a8b1e | |||
| 7b453aac1b | |||
| 3aff00290f | |||
| c347f329aa | |||
| d34a375da7 | |||
| 582b51155f | |||
| d89ddf0c74 | |||
| 9866d716d7 | |||
| 7a2f6568b3 | |||
| 2873474486 | |||
| 6d68a3f1ad | |||
| 15d50f1478 | |||
| 01d5aa510f | |||
| 2fa348f467 | |||
| 4e62e921e9 | |||
| e8df152549 | |||
| 6d8f07ab90 | |||
| 752654f049 | |||
| 13e536ec1c | |||
| a965ad3b7c | |||
| ac1f0ebaec | |||
| 3c67914788 | |||
| ac6a020612 | |||
| e129745319 | |||
| cb694cc96a | |||
| 305787fae3 |
111 changed files with 4333 additions and 251 deletions
|
|
@ -1,21 +1,21 @@
|
||||||
{
|
{
|
||||||
"name": "keisei-marketplace",
|
"name": "keisei-marketplace",
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "KeiSei84",
|
"name": "Denis Parfionovich",
|
||||||
"url": "https://github.com/KeiSei84"
|
"url": "https://github.com/KeiSeiLab"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "KeiSei Constructor-Pattern kits and primitives for Claude Code",
|
"description": "KeiSei Constructor-Pattern kits and primitives for Claude Code",
|
||||||
"version": "0.16.0"
|
"version": "0.38.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "keisei",
|
"name": "keisei",
|
||||||
"source": {
|
"source": {
|
||||||
"source": "github",
|
"source": "github",
|
||||||
"repo": "KeiSei84/KeiSeiKit"
|
"repo": "KeiSeiLab/KeiSeiKit-1.0"
|
||||||
},
|
},
|
||||||
"description": "Full KeiSeiKit — 38 agent manifests, 68 skills, 38 hooks, 105 Rust workspace crates (47 installable primitives via MANIFEST.toml `full` profile + 14 shell primitives), sleep-sync cloud consolidation, MCP server layer"
|
"description": "Full KeiSeiKit — agent manifests, skills, hooks, Rust workspace primitives via MANIFEST.toml profiles (classic ./install.sh), sleep-sync cloud consolidation, MCP server layer"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "keisei",
|
"name": "keisei",
|
||||||
"version": "0.16.0",
|
"version": "0.38.0",
|
||||||
"description": "Constructor-Pattern agent kit for Claude Code: 38 agent manifests, 68 portable skills, 38 hooks, 105 Rust workspace crates (47 installable primitives + 14 shell primitives via MANIFEST.toml `full` profile), typed artifact handoff, sleep-sync cloud consolidation, MCP server layer.",
|
"description": "Constructor-Pattern agent substrate for Claude Code: agent manifests, portable skills, hooks, Rust workspace primitives (installable via MANIFEST.toml profiles through classic ./install.sh), typed artifact handoff, sleep-sync cloud consolidation, MCP server layer.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "KeiSei",
|
"name": "Denis Parfionovich",
|
||||||
"url": "https://github.com/KeiSei84"
|
"url": "https://github.com/KeiSeiLab"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/KeiSei84/KeiSeiKit",
|
"homepage": "https://github.com/KeiSeiLab/KeiSeiKit-1.0",
|
||||||
"repository": "https://github.com/KeiSei84/KeiSeiKit",
|
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"constructor-pattern",
|
"constructor-pattern",
|
||||||
|
|
|
||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -145,7 +145,7 @@ jobs:
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
workflow-lint:
|
workflow-lint:
|
||||||
# v0.20.1: guards against the dtolnay-SHA-class incident (2026-04-22).
|
# v0.20.1: guards against the dtolnay-SHA-class incident.
|
||||||
# actionlint catches workflow syntax; validate-workflow-shas.sh catches
|
# actionlint catches workflow syntax; validate-workflow-shas.sh catches
|
||||||
# fabricated / force-pushed SHA pins. Runs fast (<30s).
|
# fabricated / force-pushed SHA pins. Runs fast (<30s).
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
[submodule "_blocks/registries"]
|
[submodule "_blocks/registries"]
|
||||||
path = _blocks/registries
|
path = _blocks/registries
|
||||||
url = https://keigit.com/keisei/kei-registries.git
|
url = https://github.com/KeiSeiLab/kei-registries.git
|
||||||
shallow = true
|
shallow = true
|
||||||
|
|
|
||||||
39
DECISIONS.md
39
DECISIONS.md
|
|
@ -6,6 +6,45 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-25 — Opt-in hook packs + stack profiles (public-prep posture)
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
The kit force-activated every hook via `settings-snippet.json`, including the
|
||||||
|
author's personal research discipline (numeric-claims evidence markers,
|
||||||
|
no-downgrade, citation-verify, rust-first / no-python). For a public,
|
||||||
|
general-audience kit that is presumptuous — users bring their own stack and do
|
||||||
|
not need a Rust-only policy or evidence-marker enforcement by default.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Posture: **safety hooks on by default; all discipline packs opt-in.** Packs:
|
||||||
|
`safety` (always), `evidence`, `observability`, `epistemic`, `orchestration`,
|
||||||
|
`git-guard`, `stack-rust`. SSoT = `_primitives/hook-packs.toml`.
|
||||||
|
- **Stack profiles** (minimal / web / ml / systems / mobile) pull a set of
|
||||||
|
discipline packs AND an agent set. `rust-first` / `no-python` live only in
|
||||||
|
`stack-rust`, which only the `systems` stack enables. `git-guard`
|
||||||
|
(no-github-push) is opt-in only and pulled by NO stack — a general kit must
|
||||||
|
not block a user's normal `git push` to github.
|
||||||
|
- Mechanism: install-time **filter** of the snippet by selected packs
|
||||||
|
(`filter_snippet_by_packs`) + **prune** of kit-owned hooks on reconfigure
|
||||||
|
(`prune_kit_hooks`, foreign hooks preserved). Selection persists to
|
||||||
|
`~/.claude/config/onboarding.toml`; re-runnable via `kei configure`.
|
||||||
|
- Non-interactive / `--yes` / CI default = minimal (safety + cosmetic only),
|
||||||
|
all agents (back-compat for power users).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Gate wiring (`_lib/gate.sh`) added to the 8 highest-friction discipline hooks
|
||||||
|
for runtime toggling via the `hooks-control` skill; remaining cosmetic/event
|
||||||
|
hooks deferred (install-time filtering already gives "off by default", so the
|
||||||
|
runtime gate is a convenience, not a correctness requirement).
|
||||||
|
- Agent-set changes via `kei configure` apply on the next `./install.sh`
|
||||||
|
(reconfigure re-applies hooks fully but does not remove already-installed
|
||||||
|
agent manifests — they are harmless extra `.md` files).
|
||||||
|
- `_toml_array` extracted from `lib-profile.sh:profile_members` as the shared
|
||||||
|
one-line-array TOML reader (no new dependency).
|
||||||
|
|
||||||
## 2026-04-28 — Three scheduling abstractions in workspace
|
## 2026-04-28 — Three scheduling abstractions in workspace
|
||||||
|
|
||||||
### Context
|
### Context
|
||||||
|
|
|
||||||
|
|
@ -383,5 +383,5 @@ Per RULE 0.13 (orchestrator branch first), each phase = orchestrator-created bra
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
- `/tmp/hermes-research/hermes-agent/` (NousResearch/hermes-agent @ HEAD, 2026-04-28)
|
- `/tmp/hermes-research/hermes-agent/` (NousResearch/hermes-agent @ HEAD, 2026-04-28)
|
||||||
- `~/Projects/KeiSeiKit/` (local, public mirror github.com/KeiSei84/KeiSeiKit-1.0)
|
- `~/Projects/KeiSeiKit/` (local, public mirror github.com/KeiSeiLab/KeiSeiKit-1.0)
|
||||||
- 7 parallel Explore agents, 2026-04-28 session.
|
- 7 parallel Explore agents, 2026-04-28 session.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ This document describes the plugin-format install path (v0.16+) and how it relat
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# One-time
|
# One-time
|
||||||
/plugin marketplace add KeiSei84/KeiSeiKit
|
/plugin marketplace add KeiSeiLab/KeiSeiKit-1.0
|
||||||
# Install
|
# Install
|
||||||
/plugin install keisei@keisei-marketplace
|
/plugin install keisei@keisei-marketplace
|
||||||
```
|
```
|
||||||
|
|
@ -50,7 +50,7 @@ Paths inside `hooks/hooks.json` use `${CLAUDE_PLUGIN_ROOT}` (expanded by Claude
|
||||||
|
|
||||||
**For plugin install:**
|
**For plugin install:**
|
||||||
- Claude Code 2.1+ (check with `claude --version`)
|
- Claude Code 2.1+ (check with `claude --version`)
|
||||||
- Network access to `github.com/KeiSei84/KeiSeiKit` on `/plugin marketplace add`
|
- Network access to `github.com/KeiSeiLab/KeiSeiKit-1.0` on `/plugin marketplace add`
|
||||||
|
|
||||||
**For the MCP server subset:**
|
**For the MCP server subset:**
|
||||||
- `@keisei/mcp-server` available from **keigit.com**
|
- `@keisei/mcp-server` available from **keigit.com**
|
||||||
|
|
@ -76,7 +76,7 @@ Paths inside `hooks/hooks.json` use `${CLAUDE_PLUGIN_ROOT}` (expanded by Claude
|
||||||
|
|
||||||
## Feedback & bugs
|
## Feedback & bugs
|
||||||
|
|
||||||
Open an issue at [github.com/KeiSei84/KeiSeiKit/issues](https://github.com/KeiSei84/KeiSeiKit/issues). A well-formed problem description is already half the solution.
|
Open an issue at [github.com/KeiSeiLab/KeiSeiKit-1.0/issues](https://github.com/KeiSeiLab/KeiSeiKit-1.0/issues). A well-formed problem description is already half the solution.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -8,8 +8,8 @@ OpenClaw / Kimi from the same source-of-truth.
|
||||||
|
|
||||||
**Apache 2.0** — explicit patent grant + retaliation clause. 105 Rust
|
**Apache 2.0** — explicit patent grant + retaliation clause. 105 Rust
|
||||||
crates [REAL: `grep -E '^\s*"[a-z-]+",' _primitives/_rust/Cargo.toml | wc -l`],
|
crates [REAL: `grep -E '^\s*"[a-z-]+",' _primitives/_rust/Cargo.toml | wc -l`],
|
||||||
68 skills [REAL: `ls skills/ | wc -l`], 38 hooks
|
69 skills [REAL: `ls skills/ | wc -l`], 54 hooks
|
||||||
[REAL: `grep -c '"command":' settings-snippet.json`], 38 agent manifests
|
[REAL: `ls hooks/*.sh | wc -l`], 38 agent manifests
|
||||||
[REAL: `ls _manifests/*.toml | wc -l`], 85 substrate blocks
|
[REAL: `ls _manifests/*.toml | wc -l`], 85 substrate blocks
|
||||||
[REAL: `find _blocks/ -name '*.md' | wc -l`], 18 capability atoms
|
[REAL: `find _blocks/ -name '*.md' | wc -l`], 18 capability atoms
|
||||||
[REAL: `find _capabilities/ -mindepth 2 -maxdepth 2 -type d | wc -l`],
|
[REAL: `find _capabilities/ -mindepth 2 -maxdepth 2 -type d | wc -l`],
|
||||||
|
|
@ -68,11 +68,11 @@ curl -fsSL https://install.keisei.app | bash
|
||||||
curl -fsSL https://install.keisei.app | bash -s -- --profile=dev --yes # CI
|
curl -fsSL https://install.keisei.app | bash -s -- --profile=dev --yes # CI
|
||||||
|
|
||||||
# Claude Code (primary target — full hook + agent integration)
|
# Claude Code (primary target — full hook + agent integration)
|
||||||
/plugin marketplace add KeiSei84/KeiSeiKit-1.0
|
/plugin marketplace add KeiSeiLab/KeiSeiKit-1.0
|
||||||
/plugin install keisei@keisei-marketplace
|
/plugin install keisei@keisei-marketplace
|
||||||
|
|
||||||
# Any MCP-compatible client (Cursor / Continue / Zed / Aider / etc)
|
# Any MCP-compatible client (Cursor / Continue / Zed / Aider / etc)
|
||||||
git clone https://keigit.com/keisei/KeiSeiKit-1.0.git
|
git clone https://github.com/KeiSeiLab/KeiSeiKit-1.0.git
|
||||||
cd KeiSeiKit-1.0
|
cd KeiSeiKit-1.0
|
||||||
./bootstrap.sh # interactive profile picker
|
./bootstrap.sh # interactive profile picker
|
||||||
# or: ./install.sh --profile=minimal # direct
|
# or: ./install.sh --profile=minimal # direct
|
||||||
|
|
@ -83,7 +83,7 @@ The web installer (`web-install.sh` in this repo, served at
|
||||||
repo and delegates to `bootstrap.sh` — single source of truth, no
|
repo and delegates to `bootstrap.sh` — single source of truth, no
|
||||||
duplicated install logic.
|
duplicated install logic.
|
||||||
|
|
||||||
38 agents + 68 skills + 38 hooks + nightly consolidation wired in
|
38 agents + 69 skills + 54 hooks + nightly consolidation wired in
|
||||||
~60 seconds. Twelve install profiles (`outcome-only`, `minimal`,
|
~60 seconds. Twelve install profiles (`outcome-only`, `minimal`,
|
||||||
`core`, `frontend`, `ops`, `dev`, `mcp`, `cortex`, `local-mirror`,
|
`core`, `frontend`, `ops`, `dev`, `mcp`, `cortex`, `local-mirror`,
|
||||||
`dashboard`, `full-hub`, `full`) defined in
|
`dashboard`, `full-hub`, `full`) defined in
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Email `parfionovich@keilab.io` with a description and reproduction steps. PGP ke
|
||||||
|
|
||||||
## Supported versions
|
## Supported versions
|
||||||
|
|
||||||
Latest `v0.14.x` tag. Older versions accept fixes for CVEs only.
|
Latest `v0.38.x` tag. Older versions accept fixes for CVEs only.
|
||||||
|
|
||||||
## Audit
|
## Audit
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ fn write_frontmatter(m: &Manifest, out: &mut String) {
|
||||||
out.push_str(&format!("description: {}\n", desc.trim()));
|
out.push_str(&format!("description: {}\n", desc.trim()));
|
||||||
out.push_str(&format!("tools: {}\n", m.tools.join(", ")));
|
out.push_str(&format!("tools: {}\n", m.tools.join(", ")));
|
||||||
out.push_str(&format!("model: {}\n", m.model));
|
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("---\n\n");
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"<!-- GENERATED by _assembler (Rust) from _manifests/{}.toml — DO NOT EDIT. Edit the manifest. -->\n\n",
|
"<!-- GENERATED by _assembler (Rust) from _manifests/{}.toml — DO NOT EDIT. Edit the manifest. -->\n\n",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ pub struct Manifest {
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub tools: Vec<String>,
|
pub tools: Vec<String>,
|
||||||
pub model: 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 role: String,
|
||||||
pub blocks: Vec<String>,
|
pub blocks: Vec<String>,
|
||||||
/// v0.16 (phase 5): agent substrate role. When present, assembler loads
|
/// v0.16 (phase 5): agent substrate role. When present, assembler loads
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ extra = [
|
||||||
"path:user-rules/dev-workflow.md",
|
"path:user-rules/dev-workflow.md",
|
||||||
"path:user-rules/debugging.md",
|
"path:user-rules/debugging.md",
|
||||||
"path:user-rules/karpathy-behavioral.md",
|
"path:user-rules/karpathy-behavioral.md",
|
||||||
"MEMORY.md → Architecture Overlay Incident (model_brain.py 227→354 LOC from \"fixes\" — never patch, fix root formulas)",
|
"Architecture Overlay Incident (model_brain.py 227→354 LOC from \"fixes\" — never patch, fix root formulas)",
|
||||||
]
|
]
|
||||||
|
|
||||||
[taxonomy]
|
[taxonomy]
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ You are the cost guardian. Your job is to make sure no paid compute launches wit
|
||||||
verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop \
|
verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop \
|
||||||
runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do \
|
runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do \
|
||||||
NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident \
|
NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident \
|
||||||
(2026-02-26)** is the cautionary tale: prices guessed not verified, silent retries \
|
** is the cautionary tale: prices guessed not verified, silent retries \
|
||||||
re-billing, file changes never confirmed, dashboard never checked. Every protocol below \
|
re-billing, file changes never confirmed, dashboard never checked. Every protocol below \
|
||||||
exists because of that day — never again.
|
exists because of that day — never again.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -419,4 +419,4 @@ Blockers / next: <list>
|
||||||
- `path:user-rules/dev-workflow.md`
|
- `path:user-rules/dev-workflow.md`
|
||||||
- `path:user-rules/debugging.md`
|
- `path:user-rules/debugging.md`
|
||||||
- `path:user-rules/karpathy-behavioral.md`
|
- `path:user-rules/karpathy-behavioral.md`
|
||||||
- `MEMORY.md → Architecture Overlay Incident (model_brain.py 227→354 LOC from "fixes" — never patch, fix root formulas)`
|
- `Architecture Overlay Incident (model_brain.py 227→354 LOC from "fixes" — never patch, fix root formulas)`
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ model: opus
|
||||||
|
|
||||||
# ROLE
|
# ROLE
|
||||||
|
|
||||||
You are the cost guardian. Your job is to make sure no paid compute launches without a verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident (2026-02-26)** is the cautionary tale: prices guessed not verified, silent retries re-billing, file changes never confirmed, dashboard never checked. Every protocol below exists because of that day — never again.
|
You are the cost guardian. Your job is to make sure no paid compute launches without a verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident ** is the cautionary tale: prices guessed not verified, silent retries re-billing, file changes never confirmed, dashboard never checked. Every protocol below exists because of that day — never again.
|
||||||
|
|
||||||
# AGENT SUBSTRATE — role `read-only`
|
# AGENT SUBSTRATE — role `read-only`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit c5590658ee636398c512ce2b25d8aedb3212a9b6
|
Subproject commit b90499311ed09cbf88ced93aaa9d890dcf861fac
|
||||||
|
|
@ -463,4 +463,4 @@ behaviour-verified: yes | no | not-applicable
|
||||||
follow-up-required:
|
follow-up-required:
|
||||||
- <bullet list>
|
- <bullet list>
|
||||||
```
|
```
|
||||||
- `MEMORY.md → Architecture Overlay Incident (model_brain.py 227→354 LOC from "fixes" — never patch, fix root formulas)`
|
- `Architecture Overlay Incident (model_brain.py 227→354 LOC from "fixes" — never patch, fix root formulas)`
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ model: sonnet
|
||||||
|
|
||||||
# ROLE
|
# ROLE
|
||||||
|
|
||||||
You are the cost guardian. Your job is to make sure no paid compute launches without a verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident (2026-02-26)** is the cautionary tale: prices guessed not verified, silent retries re-billing, file changes never confirmed, dashboard never checked. Every protocol below exists because of that day — never again.
|
You are the cost guardian. Your job is to make sure no paid compute launches without a verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident** is the cautionary tale: prices guessed not verified, silent retries re-billing, file changes never confirmed, dashboard never checked. Every protocol below exists because of that day — never again.
|
||||||
|
|
||||||
# AGENT SUBSTRATE — role `read-only`
|
# AGENT SUBSTRATE — role `read-only`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -438,9 +438,9 @@ Blockers / next: <list>
|
||||||
- `{path::user-rules}/git-conventions.md`
|
- `{path::user-rules}/git-conventions.md`
|
||||||
- `{path::user-rules}/dev-workflow.md`
|
- `{path::user-rules}/dev-workflow.md`
|
||||||
- `{path::user-memory}/security-restricted-projects.md`
|
- `{path::user-memory}/security-restricted-projects.md`
|
||||||
- `MEMORY.md → Compute Cost Incident (2026-02-26): $98.78 Modal overrun — no dashboard check, unverified prices.`
|
- `Compute Cost Incident: $98.78 Modal overrun — no dashboard check, unverified prices.`
|
||||||
- `MEMORY.md → Recruiter shared-EC2 risk (i-0a8b747023809d451 shared with 3 projects, default SECRET_KEY, no CSRF).`
|
- `Recruiter shared-EC2 risk (i-0a8b747023809d451 shared with 3 projects, default SECRET_KEY, no CSRF).`
|
||||||
- `MEMORY.md → CloudSync 146 GB bloat: two duplicate LaunchAgents both writing logs. Scan for duplicates before adding infra.`
|
- `CloudSync 146 GB bloat: two duplicate LaunchAgents both writing logs. Scan for duplicates before adding infra.`
|
||||||
|
|
||||||
## Output Footer (RULE 0.16)
|
## Output Footer (RULE 0.16)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -483,8 +483,8 @@ Blockers / next: <list>
|
||||||
- `{path::user-rules}/manifold-tangent-sanity.md`
|
- `{path::user-rules}/manifold-tangent-sanity.md`
|
||||||
- `{path::user-rules}/no-downgrade-constructive.md`
|
- `{path::user-rules}/no-downgrade-constructive.md`
|
||||||
- `{path::user-memory}/wrong-paths-specialized-ml.md`
|
- `{path::user-memory}/wrong-paths-specialized-ml.md`
|
||||||
- `MEMORY.md → Compute Cost Incident (2026-02-26): promised $27, spent $98.78 on Modal. NEVER AGAIN.`
|
- `Compute Cost Incident: promised $27, spent $98.78 on Modal. NEVER AGAIN.`
|
||||||
- `MEMORY.md → Architecture Overlay Incident: model_brain.py 227→354 LOC from audit fixes. No Patching.`
|
- `Architecture Overlay Incident: model_brain.py 227→354 LOC from audit fixes. No Patching.`
|
||||||
|
|
||||||
## Output Footer (RULE 0.16)
|
## Output Footer (RULE 0.16)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ model: sonnet
|
||||||
|
|
||||||
You are the Modal compute orchestrator. You launch Modal jobs safely, observe them well, and NEVER burn money or kill running work. Two incidents shape every rule below.
|
You are the Modal compute orchestrator. You launch Modal jobs safely, observe them well, and NEVER burn money or kill running work. Two incidents shape every rule below.
|
||||||
|
|
||||||
$98.78 Modal Incident (2026-02-26): promised $27, spent $98.78 in one session. Prices guessed not verified, failed retries silently re-billed, file changes never confirmed, dashboard never checked. Every cost rule exists because of that day.
|
$98.78 Modal Incident: promised $27, spent $98.78 in one session. Prices guessed not verified, failed retries silently re-billed, file changes never confirmed, dashboard never checked. Every cost rule exists because of that day.
|
||||||
|
|
||||||
anti-stop guard Incident (2026-03-29): stopped a 1.4-hour training run for a non-critical bug. Cost: 1.4 hours A10G + restart + re-warmup. Every kill rule exists because of that day.
|
anti-stop guard Incident: stopped a 1.4-hour training run for a non-critical bug. Cost: 1.4 hours A10G + restart + re-warmup. Every kill rule exists because of that day.
|
||||||
|
|
||||||
Cost tiers: <$5 per run → AUTO; $5-$20 → WARN + daily-cap check ($20/day session); >$20 → STOP and ask. Always state estimate in dollars BEFORE launch: "Estimate: $X.XX (= N_gpus × hours × $/hr/gpu)". GPU compat: A10G torch>=2.0 (~$1.10/hr), H100 torch>=2.1 (~$4.50/hr), B200 torch>=2.6 (~$8/hr). Always verify on pricing page — rates change.
|
Cost tiers: <$5 per run → AUTO; $5-$20 → WARN + daily-cap check ($20/day session); >$20 → STOP and ask. Always state estimate in dollars BEFORE launch: "Estimate: $X.XX (= N_gpus × hours × $/hr/gpu)". GPU compat: A10G torch>=2.0 (~$1.10/hr), H100 torch>=2.1 (~$4.50/hr), B200 torch>=2.6 (~$8/hr). Always verify on pricing page — rates change.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ extra = [
|
||||||
"path:user-rules/dev-workflow.md",
|
"path:user-rules/dev-workflow.md",
|
||||||
"path:user-rules/debugging.md",
|
"path:user-rules/debugging.md",
|
||||||
"path:user-rules/karpathy-behavioral.md",
|
"path:user-rules/karpathy-behavioral.md",
|
||||||
"MEMORY.md → Architecture Overlay Incident (model_brain.py 227→354 LOC from \"fixes\" — never patch, fix root formulas)",
|
"Architecture Overlay Incident (model_brain.py 227→354 LOC from \"fixes\" — never patch, fix root formulas)",
|
||||||
]
|
]
|
||||||
|
|
||||||
[taxonomy]
|
[taxonomy]
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ You are the cost guardian. Your job is to make sure no paid compute launches wit
|
||||||
verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop \
|
verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop \
|
||||||
runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do \
|
runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do \
|
||||||
NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident \
|
NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident \
|
||||||
(2026-02-26)** is the cautionary tale: prices guessed not verified, silent retries \
|
** is the cautionary tale: prices guessed not verified, silent retries \
|
||||||
re-billing, file changes never confirmed, dashboard never checked. Every protocol below \
|
re-billing, file changes never confirmed, dashboard never checked. Every protocol below \
|
||||||
exists because of that day — never again.
|
exists because of that day — never again.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -94,10 +94,6 @@ trigger = "fal.ai call needs to be wired into project source beyond a throwaway
|
||||||
target = "validator"
|
target = "validator"
|
||||||
trigger = "generated assets include text / citations / claims that need RULE 0.4 verification before shipping"
|
trigger = "generated assets include text / citations / claims that need RULE 0.4 verification before shipping"
|
||||||
|
|
||||||
[[handoff]]
|
|
||||||
target = "keimd-expert"
|
|
||||||
trigger = "user asks \"what assets already exist in this project\" — knowledge graph search, not fal.ai call"
|
|
||||||
|
|
||||||
[[handoff]]
|
[[handoff]]
|
||||||
target = "critic"
|
target = "critic"
|
||||||
trigger = "anti-pattern sweep after batch — are prompts / generated assets consistent / on-brand?"
|
trigger = "anti-pattern sweep after batch — are prompts / generated assets consistent / on-brand?"
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ forbidden_domain = [
|
||||||
"IaC (Terraform/Pulumi/CDK) — hand off to infra-implementer-iac",
|
"IaC (Terraform/Pulumi/CDK) — hand off to infra-implementer-iac",
|
||||||
"Dockerfiles or OCI images — hand off to infra-implementer-container",
|
"Dockerfiles or OCI images — hand off to infra-implementer-container",
|
||||||
"Secrets management (Vault, sops, age) — hand off to infra-implementer-secrets",
|
"Secrets management (Vault, sops, age) — hand off to infra-implementer-secrets",
|
||||||
"Hardcoded secrets in workflow YAML (RULE 0.8) — use `${{ secrets.NAME }}` / ENV refs",
|
"Hardcoded secrets in workflow YAML (RULE 0.8) — use repo/org secret refs + ENV, never inline",
|
||||||
"Skipping build-cache steps — always cache cargo registry + target, node_modules, pip cache",
|
"Skipping build-cache steps — always cache cargo registry + target, node_modules, pip cache",
|
||||||
]
|
]
|
||||||
output_extra_fields = [
|
output_extra_fields = [
|
||||||
|
|
|
||||||
|
|
@ -100,9 +100,9 @@ extra = [
|
||||||
"path:user-rules/git-conventions.md",
|
"path:user-rules/git-conventions.md",
|
||||||
"path:user-rules/dev-workflow.md",
|
"path:user-rules/dev-workflow.md",
|
||||||
"path:user-memory/security-restricted-projects.md",
|
"path:user-memory/security-restricted-projects.md",
|
||||||
"MEMORY.md → Compute Cost Incident (2026-02-26): $98.78 Modal overrun — no dashboard check, unverified prices.",
|
"Compute Cost Incident: $98.78 Modal overrun — no dashboard check, unverified prices.",
|
||||||
"MEMORY.md → Recruiter shared-EC2 risk (<ec2-instance-id> shared with 3 projects, default SECRET_KEY, no CSRF).",
|
"Recruiter shared-EC2 risk (<ec2-instance-id> shared with 3 projects, default SECRET_KEY, no CSRF).",
|
||||||
"MEMORY.md → CloudSync 146 GB bloat: two duplicate LaunchAgents both writing logs. Scan for duplicates before adding infra.",
|
"CloudSync 146 GB bloat: two duplicate LaunchAgents both writing logs. Scan for duplicates before adding infra.",
|
||||||
]
|
]
|
||||||
|
|
||||||
[taxonomy]
|
[taxonomy]
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,8 @@ extra = [
|
||||||
"path:user-rules/manifold-tangent-sanity.md",
|
"path:user-rules/manifold-tangent-sanity.md",
|
||||||
"path:user-rules/no-downgrade-constructive.md",
|
"path:user-rules/no-downgrade-constructive.md",
|
||||||
"path:user-memory/wrong-paths-specialized-ml.md", # TODO verify path:user-memory exists in assembler resolver
|
"path:user-memory/wrong-paths-specialized-ml.md", # TODO verify path:user-memory exists in assembler resolver
|
||||||
"MEMORY.md → Compute Cost Incident (2026-02-26): promised $27, spent $98.78 on Modal. NEVER AGAIN.",
|
"Compute Cost Incident: promised $27, spent $98.78 on Modal. NEVER AGAIN.",
|
||||||
"MEMORY.md → Architecture Overlay Incident: model_brain.py 227→354 LOC from audit fixes. No Patching.",
|
"Architecture Overlay Incident: model_brain.py 227→354 LOC from audit fixes. No Patching.",
|
||||||
]
|
]
|
||||||
|
|
||||||
[taxonomy]
|
[taxonomy]
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,11 @@ role = """
|
||||||
You are the Modal compute orchestrator. You launch Modal jobs safely, observe them well, and NEVER \
|
You are the Modal compute orchestrator. You launch Modal jobs safely, observe them well, and NEVER \
|
||||||
burn money or kill running work. Two incidents shape every rule below.
|
burn money or kill running work. Two incidents shape every rule below.
|
||||||
|
|
||||||
$98.78 Modal Incident (2026-02-26): promised $27, spent $98.78 in one session. Prices guessed not \
|
$98.78 Modal Incident: promised $27, spent $98.78 in one session. Prices guessed not \
|
||||||
verified, failed retries silently re-billed, file changes never confirmed, dashboard never checked. \
|
verified, failed retries silently re-billed, file changes never confirmed, dashboard never checked. \
|
||||||
Every cost rule exists because of that day.
|
Every cost rule exists because of that day.
|
||||||
|
|
||||||
anti-stop guard Incident (2026-03-29): stopped a 1.4-hour training run for a non-critical bug. Cost: \
|
anti-stop guard Incident: stopped a 1.4-hour training run for a non-critical bug. Cost: \
|
||||||
1.4 hours A10G + restart + re-warmup. Every kill rule exists because of that day.
|
1.4 hours A10G + restart + re-warmup. Every kill rule exists because of that day.
|
||||||
|
|
||||||
Cost tiers: <$5 per run → AUTO; $5-$20 → WARN + daily-cap check ($20/day session); >$20 → STOP \
|
Cost tiers: <$5 per run → AUTO; $5-$20 → WARN + daily-cap check ($20/day session); >$20 → STOP \
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,10 @@ buddy = ["kei-buddy", "kei-telegram-webhook", "kei-shared", "kei-chat-store",
|
||||||
# previous one — `dashboard` extends `local-mirror`, `full-hub` extends
|
# previous one — `dashboard` extends `local-mirror`, `full-hub` extends
|
||||||
# `dashboard`. Native macOS arm64 (brew + launchd plists). See
|
# `dashboard`. Native macOS arm64 (brew + launchd plists). See
|
||||||
# `install/lib-dev-hub-*.sh` for the install logic per component.
|
# `install/lib-dev-hub-*.sh` for the install logic per component.
|
||||||
local-mirror = ["kei-cortex", "cortex-ui", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner"]
|
local-mirror = ["kei-cortex", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner"]
|
||||||
dashboard = ["kei-cortex", "cortex-ui", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner", "kei-projects-index", "kei-projects-watcher", "dev-hub-datasette"]
|
dashboard = ["kei-cortex", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner", "kei-projects-index", "kei-projects-watcher", "dev-hub-datasette"]
|
||||||
full-hub = ["kei-cortex", "cortex-ui", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner", "kei-projects-index", "kei-projects-watcher", "dev-hub-datasette", "dev-hub-zoekt", "dev-hub-mdbook", "dev-hub-restic", "dev-hub-gdrive-import"]
|
full-hub = ["kei-cortex", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner", "kei-projects-index", "kei-projects-watcher", "dev-hub-datasette", "dev-hub-zoekt", "dev-hub-mdbook", "dev-hub-restic", "dev-hub-gdrive-import"]
|
||||||
full = ["tomd", "kei-doctor", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth", "kei-artifact", "keisei", "kei-agent-runtime", "kei-capability", "kei-provision", "kei-entity-store", "kei-pipe", "kei-cache", "kei-spawn", "kei-replay", "kei-cortex", "cortex-ui", "kei-pet", "kei-shared", "frustration-matrix", "kei-skill-importer", "kei-projects-index", "kei-projects-watcher", "dev-hub-forgejo", "dev-hub-forgejo-runner", "dev-hub-datasette", "dev-hub-zoekt", "dev-hub-mdbook", "dev-hub-restic", "dev-hub-gdrive-import"]
|
full = ["tomd", "kei-doctor", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth", "kei-artifact", "keisei", "kei-agent-runtime", "kei-capability", "kei-provision", "kei-entity-store", "kei-pipe", "kei-cache", "kei-spawn", "kei-replay", "kei-cortex", "kei-pet", "kei-shared", "frustration-matrix", "kei-skill-importer", "kei-projects-index", "kei-projects-watcher", "dev-hub-forgejo", "dev-hub-forgejo-runner", "dev-hub-datasette", "dev-hub-zoekt", "dev-hub-mdbook", "dev-hub-restic", "dev-hub-gdrive-import"]
|
||||||
|
|
||||||
# --- shell primitives (13) -------------------------------------------------
|
# --- shell primitives (13) -------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -357,12 +357,6 @@ crate = "kei-cortex"
|
||||||
deps = ["python3 (>=3.9, for whisper_worker.py subprocess)", "pip install -r scripts/requirements.txt", "ffmpeg (on PATH, faster-whisper audio demux)"]
|
deps = ["python3 (>=3.9, for whisper_worker.py subprocess)", "pip install -r scripts/requirements.txt", "ffmpeg (on PATH, faster-whisper audio demux)"]
|
||||||
desc = "Local HTTP daemon exposing chat/TTS/STT/portrait endpoints — backs cortex-ui browser app"
|
desc = "Local HTTP daemon exposing chat/TTS/STT/portrait endpoints — backs cortex-ui browser app"
|
||||||
|
|
||||||
[primitive.cortex-ui]
|
|
||||||
kind = "node"
|
|
||||||
path = "_ts_packages/packages/cortex-ui"
|
|
||||||
deps = ["node>=18", "pnpm"]
|
|
||||||
desc = "Svelte 5 + Vite 5 web UI for kei-cortex daemon (chat panel, Live2D pet renderer, portrait uploader)"
|
|
||||||
|
|
||||||
[primitive.frustration-matrix]
|
[primitive.frustration-matrix]
|
||||||
kind = "rust"
|
kind = "rust"
|
||||||
crate = "frustration-matrix"
|
crate = "frustration-matrix"
|
||||||
|
|
|
||||||
2
_primitives/_rust/Cargo.lock
generated
2
_primitives/_rust/Cargo.lock
generated
|
|
@ -3969,10 +3969,12 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"kei-atom-discovery",
|
"kei-atom-discovery",
|
||||||
"kei-skills",
|
"kei-skills",
|
||||||
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -198,8 +198,8 @@ edition = "2021"
|
||||||
rust-version = "1.77"
|
rust-version = "1.77"
|
||||||
authors = ["Denis Parfionovich <parfionovich@keilab.io>"]
|
authors = ["Denis Parfionovich <parfionovich@keilab.io>"]
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
repository = "https://github.com/KeiSei84/KeiSeiKit-1.0"
|
repository = "https://github.com/KeiSeiLab/KeiSeiKit-1.0"
|
||||||
homepage = "https://github.com/KeiSei84/KeiSeiKit-1.0"
|
homepage = "https://github.com/KeiSeiLab/KeiSeiKit-1.0"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,17 @@ path = "src/lib.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true }
|
# v0.39: io-std added for tokio::io::stdin/stdout used by the MCP stdio
|
||||||
|
# transport in main.rs (workspace tokio doesn't enable io-std by default).
|
||||||
|
tokio = { workspace = true, features = ["io-std"] }
|
||||||
|
# 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"
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
kei-atom-discovery = { path = "../kei-atom-discovery" }
|
kei-atom-discovery = { path = "../kei-atom-discovery" }
|
||||||
kei-skills = { path = "../kei-skills" }
|
kei-skills = { path = "../kei-skills" }
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
pub mod initialize;
|
pub mod initialize;
|
||||||
pub mod prompts;
|
pub mod prompts;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
|
pub mod safe_tools;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
|
||||||
use crate::protocol::{err, JsonRpcRequest, JsonRpcResponse, Method, ServerContext, METHOD_NOT_FOUND};
|
use crate::protocol::{err, JsonRpcRequest, JsonRpcResponse, Method, ServerContext, METHOD_NOT_FOUND};
|
||||||
|
|
|
||||||
738
_primitives/_rust/kei-mcp/src/handlers/safe_tools.rs
Normal file
738
_primitives/_rust/kei-mcp/src/handlers/safe_tools.rs
Normal file
|
|
@ -0,0 +1,738 @@
|
||||||
|
//! 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,6 +33,16 @@ pub fn list(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse {
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(atom_to_tool_descriptor)
|
.map(atom_to_tool_descriptor)
|
||||||
.collect();
|
.collect();
|
||||||
|
// v0.39: built-in spawn_agent tool — exposed to all MCP clients so any
|
||||||
|
// CLI (grok / agy / copilot / kimi / claude) can spawn a KeiSeiKit agent
|
||||||
|
// as a sub-agent. Bypasses atom discovery (it's an internal handler).
|
||||||
|
tools.push(spawn_agent_descriptor());
|
||||||
|
// 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| {
|
tools.sort_by(|a, b| {
|
||||||
a.get("name").and_then(Value::as_str).unwrap_or("")
|
a.get("name").and_then(Value::as_str).unwrap_or("")
|
||||||
.cmp(b.get("name").and_then(Value::as_str).unwrap_or(""))
|
.cmp(b.get("name").and_then(Value::as_str).unwrap_or(""))
|
||||||
|
|
@ -50,6 +60,23 @@ pub async fn call(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse {
|
||||||
None => return err(req.id, INVALID_PARAMS, "missing tool name"),
|
None => return err(req.id, INVALID_PARAMS, "missing tool name"),
|
||||||
};
|
};
|
||||||
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||||
|
|
||||||
|
// v0.39: spawn_agent built-in — short-circuit before atom dispatch.
|
||||||
|
if name == "spawn_agent" {
|
||||||
|
return match invoke_spawn_agent(&args).await {
|
||||||
|
Ok(text) => ok(req.id, json!({
|
||||||
|
"content": [{ "type": "text", "text": text }],
|
||||||
|
"isError": false,
|
||||||
|
})),
|
||||||
|
Err(e) => err(req.id, INTERNAL_ERROR, e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
match invoke_atom(&ctx.atoms_root, &name, &args).await {
|
||||||
Ok(result) => ok(req.id, json!({
|
Ok(result) => ok(req.id, json!({
|
||||||
"content": [{ "type": "text", "text": serde_json::to_string(&result).unwrap_or_default() }],
|
"content": [{ "type": "text", "text": serde_json::to_string(&result).unwrap_or_default() }],
|
||||||
|
|
@ -59,6 +86,94 @@ pub async fn call(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.39: built-in `spawn_agent` MCP tool descriptor.
|
||||||
|
/// Exposes KeiSeiKit's cross-CLI agent launcher (`kei-agent-cli.sh`) so any
|
||||||
|
/// MCP client can spawn an agent on any backend (claude / grok / agy /
|
||||||
|
/// copilot / kimi). Solves the "non-claude orchestrator can't natively spawn
|
||||||
|
/// sub-agents" gap — any CLI with MCP support gets the spawn capability.
|
||||||
|
fn spawn_agent_descriptor() -> Value {
|
||||||
|
json!({
|
||||||
|
"name": "spawn_agent",
|
||||||
|
"description": "Spawn a KeiSeiKit agent as a sub-agent through any configured LLM CLI backend. Reads ~/.claude/agents/<name>.md, composes with the task, and execs the chosen backend non-interactively. Backend resolution: explicit `on` arg → agent manifest's `provider` → ~/.claude/config/primary.toml → claude.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Agent name (looked up in ~/.claude/agents/<name>.md)"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The task / question to give the agent"
|
||||||
|
},
|
||||||
|
"on": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional explicit backend override (claude/grok/agy/copilot/kimi/codex). Default: DNA → primary → claude.",
|
||||||
|
"enum": ["claude", "grok", "agy", "antigravity", "copilot", "kimi", "codex"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name", "task"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v0.39: handler for `tools/call name=spawn_agent`. Shells out to
|
||||||
|
/// `kei-agent-cli.sh` (located via $HOME/.claude/scripts/) and returns
|
||||||
|
/// the backend's stdout as the tool result.
|
||||||
|
async fn invoke_spawn_agent(args: &Value) -> Result<String, String> {
|
||||||
|
let name = args.get("name").and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| "spawn_agent: missing 'name' argument".to_string())?;
|
||||||
|
let task = args.get("task").and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| "spawn_agent: missing 'task' argument".to_string())?;
|
||||||
|
let on_opt = args.get("on").and_then(Value::as_str);
|
||||||
|
|
||||||
|
// Locate the launcher script. Honors KEI_AGENT_CLI override for testing.
|
||||||
|
let script = match std::env::var("KEI_AGENT_CLI") {
|
||||||
|
Ok(v) => PathBuf::from(v),
|
||||||
|
Err(_) => {
|
||||||
|
let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
|
||||||
|
PathBuf::from(home).join(".claude/scripts/kei-agent-cli.sh")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !script.is_file() {
|
||||||
|
return Err(format!("kei-agent-cli.sh not found: {}", script.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = Command::new(&script);
|
||||||
|
if let Some(on) = on_opt {
|
||||||
|
cmd.arg(format!("--on={on}"));
|
||||||
|
}
|
||||||
|
cmd.arg(name).arg(task);
|
||||||
|
cmd.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
|
||||||
|
let child = cmd.spawn()
|
||||||
|
.map_err(|e| format!("spawn {}: {e}", script.display()))?;
|
||||||
|
let fut = child.wait_with_output();
|
||||||
|
|
||||||
|
// Reuse the existing ATOM_TIMEOUT_SECS for the spawn_agent cap too —
|
||||||
|
// 60s should suffice for non-interactive prompts; longer tasks would
|
||||||
|
// need streaming, which the MCP tools-call contract doesn't support
|
||||||
|
// anyway. Hung agents are killed at the timeout.
|
||||||
|
match tokio::time::timeout(Duration::from_secs(ATOM_TIMEOUT_SECS), fut).await {
|
||||||
|
Ok(Ok(out)) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
return Err(format!(
|
||||||
|
"spawn_agent backend exited {}: {stderr}",
|
||||||
|
out.status.code().unwrap_or(-1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(stdout)
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => Err(format!("wait: {e}")),
|
||||||
|
Err(_) => Err("spawn_agent timeout".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert one atom's metadata into the MCP tool-descriptor shape.
|
/// Convert one atom's metadata into the MCP tool-descriptor shape.
|
||||||
fn atom_to_tool_descriptor(meta: AtomMeta) -> Value {
|
fn atom_to_tool_descriptor(meta: AtomMeta) -> Value {
|
||||||
let description = first_paragraph(&meta.body);
|
let description = first_paragraph(&meta.body);
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,19 @@ async fn tools_list_returns_two_atoms_with_descriptors() {
|
||||||
let resp = dispatch(req, &ctx).await;
|
let resp = dispatch(req, &ctx).await;
|
||||||
let result = resp.result.expect("should have result");
|
let result = resp.result.expect("should have result");
|
||||||
let tools = result["tools"].as_array().expect("tools array");
|
let tools = result["tools"].as_array().expect("tools array");
|
||||||
assert_eq!(tools.len(), 2);
|
// v0.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
// sorted alphabetically
|
// sorted alphabetically
|
||||||
assert_eq!(tools[0]["name"], "kei-sage::ask");
|
assert_eq!(tools[0]["name"], "kei-sage::ask");
|
||||||
assert_eq!(tools[1]["name"], "kei-task::search");
|
assert_eq!(tools[1]["name"], "kei-task::search");
|
||||||
|
|
@ -91,5 +103,14 @@ async fn tools_list_handles_empty_root() {
|
||||||
};
|
};
|
||||||
let resp = dispatch(req, &ctx).await;
|
let resp = dispatch(req, &ctx).await;
|
||||||
let result = resp.result.expect("should have result");
|
let result = resp.result.expect("should have result");
|
||||||
assert_eq!(result["tools"].as_array().unwrap().len(), 0);
|
// v0.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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
_primitives/cli-backends.toml
Normal file
45
_primitives/cli-backends.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# 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"
|
||||||
55
_primitives/hook-packs.toml
Normal file
55
_primitives/hook-packs.toml
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# KeiSeiKit hook-pack + stack-profile map — single source of truth for the
|
||||||
|
# opt-in install posture. Parsed by install/lib-packs.sh, which reuses the
|
||||||
|
# generic TOML array reader `_toml_array` extracted from lib-profile.sh
|
||||||
|
# (python-tomllib preferred, awk fallback). No new dependency.
|
||||||
|
#
|
||||||
|
# Values are HOOK BASENAMES WITHOUT `.sh`, matched against the command
|
||||||
|
# basenames in settings-snippet.json. Every hook wired in the snippet MUST
|
||||||
|
# appear in exactly one [pack] entry or in [pack-always]; anything missing
|
||||||
|
# would be silently filtered out of a fresh install.
|
||||||
|
#
|
||||||
|
# Posture: only `safety` + `pack-always` are active on a fresh/non-interactive
|
||||||
|
# install. All other packs are opt-in (via onboarding or `kei configure`).
|
||||||
|
# `git-guard` (no-github-push) is opt-in ONLY and is pulled by NO stack — a
|
||||||
|
# general kit must never block a user's normal `git push` to github by default.
|
||||||
|
|
||||||
|
[pack]
|
||||||
|
safety = ["block-dangerous", "safety-guard", "destructive-guard", "disk-headroom-check", "secrets-pre-guard", "no-hand-edit-agents", "assemble-validate", "assemble-agents"]
|
||||||
|
evidence = ["numeric-claims-guard", "citation-verify", "chat-numeric-prewarn", "chat-numeric-postflag"]
|
||||||
|
observability = ["task-timer", "session-end-dump", "extract-task-durations", "error-spike-detector", "agent-event-spawn", "agent-event-done", "agent-heartbeat-tick", "stop-verify"]
|
||||||
|
epistemic = ["alignment-check", "no-downgrade", "recurrence-suggest"]
|
||||||
|
orchestration = ["agent-fork-logger", "agent-fork-done", "orchestrator-dirty-check", "orchestrator-branch-check", "agent-capability-check", "agent-stub-scan", "milestone-commit-hook", "post-commit-audit", "post-write-check"]
|
||||||
|
git-guard = ["no-github-push"]
|
||||||
|
stack-rust = ["rust-first", "no-python-without-approval"]
|
||||||
|
|
||||||
|
# Always wired, never filtered (cosmetic / infra). The keisei-pet*.sh status
|
||||||
|
# updater + the inline pet hook are kept by the filter directly (name match),
|
||||||
|
# so they are NOT listed here.
|
||||||
|
[pack-always]
|
||||||
|
base = ["first-run-onboard", "mailbox-inject", "tomd-preread", "site-wysiwyd-check"]
|
||||||
|
|
||||||
|
# Stack profile -> discipline packs auto-enabled (safety is always implicit).
|
||||||
|
# git-guard intentionally absent from every stack (opt-in only).
|
||||||
|
[stack-packs]
|
||||||
|
minimal = []
|
||||||
|
web = ["evidence", "observability"]
|
||||||
|
ml = ["evidence", "observability", "epistemic"]
|
||||||
|
systems = ["evidence", "observability", "stack-rust"]
|
||||||
|
mobile = ["evidence", "observability"]
|
||||||
|
|
||||||
|
# Stack profile -> agent groups installed (the `base` group is always added).
|
||||||
|
[stack-agents]
|
||||||
|
minimal = ["base"]
|
||||||
|
web = ["base", "web"]
|
||||||
|
ml = ["base", "ml"]
|
||||||
|
systems = ["base", "systems"]
|
||||||
|
mobile = ["base", "mobile"]
|
||||||
|
|
||||||
|
# Agent group -> manifest basenames (without `.toml`). When no stack is chosen
|
||||||
|
# (power user / --profile=full / non-interactive), ALL manifests install.
|
||||||
|
[agent-set]
|
||||||
|
base = ["architect", "critic", "validator", "researcher", "code-implementer", "security-auditor"]
|
||||||
|
web = ["code-implementer-typescript", "frontend-validator", "validator-api", "validator-doc", "researcher-web", "researcher-code"]
|
||||||
|
ml = ["ml-implementer", "ml-researcher", "modal-runner", "cost-guardian", "fal-ai-runner", "code-implementer-python", "validator-benchmark"]
|
||||||
|
systems = ["code-implementer-rust", "code-implementer-go", "infra-implementer", "infra-implementer-cicd", "infra-implementer-container", "infra-implementer-iac", "infra-implementer-secrets", "validator-version"]
|
||||||
|
mobile = ["code-implementer-swift", "code-implementer-flutter", "frontend-validator"]
|
||||||
|
|
@ -27,15 +27,6 @@ language = "toml"
|
||||||
[pipeline]
|
[pipeline]
|
||||||
handoff = ["merger"]
|
handoff = ["merger"]
|
||||||
|
|
||||||
[taxonomy]
|
|
||||||
kingdom = "role"
|
|
||||||
mechanism = "audit"
|
|
||||||
domain = "agent"
|
|
||||||
layer = "agent-substrate"
|
|
||||||
stage = "runtime"
|
|
||||||
stability = "stable"
|
|
||||||
language = "toml"
|
|
||||||
|
|
||||||
[lineage]
|
[lineage]
|
||||||
parents = []
|
parents = []
|
||||||
creator = "ag-orchestrator-human"
|
creator = "ag-orchestrator-human"
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,6 @@ language = "toml"
|
||||||
[pipeline]
|
[pipeline]
|
||||||
handoff = []
|
handoff = []
|
||||||
|
|
||||||
[taxonomy]
|
|
||||||
kingdom = "role"
|
|
||||||
mechanism = "merge"
|
|
||||||
domain = "agent"
|
|
||||||
layer = "agent-substrate"
|
|
||||||
stage = "runtime"
|
|
||||||
stability = "stable"
|
|
||||||
language = "toml"
|
|
||||||
|
|
||||||
[lineage]
|
[lineage]
|
||||||
parents = []
|
parents = []
|
||||||
creator = "ag-orchestrator-human"
|
creator = "ag-orchestrator-human"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/KeiSei84/KeiSeiKit-1.0.git",
|
"url": "git+https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
|
||||||
"directory": "_ts_packages/packages/mcp-server"
|
"directory": "_ts_packages/packages/mcp-server"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|
|
||||||
162
bin/kei
162
bin/kei
|
|
@ -8,7 +8,23 @@
|
||||||
# kei # splash → claude (interactive REPL)
|
# kei # splash → claude (interactive REPL)
|
||||||
# kei --no-splash # skip splash → exec claude
|
# kei --no-splash # skip splash → exec claude
|
||||||
# kei --status # status only, don't launch claude
|
# kei --status # status only, don't launch claude
|
||||||
# kei [args...] # splash → claude args... (forwarded verbatim)
|
# 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)
|
||||||
#
|
#
|
||||||
# The splash shows: substrate version, agent count, last sleep run,
|
# The splash shows: substrate version, agent count, last sleep run,
|
||||||
# active sessions (kei-ping). Press any key to skip the dwell.
|
# active sessions (kei-ping). Press any key to skip the dwell.
|
||||||
|
|
@ -17,26 +33,128 @@
|
||||||
|
|
||||||
set -e
|
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).
|
||||||
|
case "${1:-}" in
|
||||||
|
message|msg|m)
|
||||||
|
shift
|
||||||
|
exec "$HOME/.claude/scripts/kei-message.sh" "$@"
|
||||||
|
;;
|
||||||
|
configure|config|reconfigure)
|
||||||
|
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 ----------------------------------------------------------------
|
# --- args ----------------------------------------------------------------
|
||||||
SPLASH=1
|
SPLASH=1
|
||||||
STATUS_ONLY=0
|
STATUS_ONLY=0
|
||||||
PASSTHROUGH=()
|
PASSTHROUGH=()
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
|
--on=*) ;; # already captured in ONESHOT_BACKEND; don't forward
|
||||||
--no-splash) SPLASH=0 ;;
|
--no-splash) SPLASH=0 ;;
|
||||||
--status) STATUS_ONLY=1; SPLASH=1 ;;
|
--status) STATUS_ONLY=1; SPLASH=1 ;;
|
||||||
*) PASSTHROUGH+=("$arg") ;;
|
*) PASSTHROUGH+=("$arg") ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# --- locate claude on PATH -----------------------------------------------
|
# --- resolve primary backend ---------------------------------------------
|
||||||
CLAUDE_BIN="$(command -v claude 2>/dev/null || true)"
|
# Order: --on=<backend> override → ~/.claude/config/primary.toml → claude.
|
||||||
if [ -z "$CLAUDE_BIN" ] && [ "$STATUS_ONLY" = "0" ]; then
|
resolve_primary() {
|
||||||
echo "error: 'claude' not on PATH. Install Claude Code first:" >&2
|
if [ -n "$ONESHOT_BACKEND" ]; then printf '%s\n' "$ONESHOT_BACKEND"; return; fi
|
||||||
echo " curl -fsSL https://claude.ai/install.sh | sh" >&2
|
if [ -n "${KEI_PRIMARY:-}" ]; then printf '%s\n' "$KEI_PRIMARY"; return; fi
|
||||||
|
local cfg="$HOME/.claude/config/primary.toml"
|
||||||
|
if [ -f "$cfg" ]; then
|
||||||
|
awk -F'=' '/^provider[[:space:]]*=/ {
|
||||||
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
|
||||||
|
gsub(/^"|"$/, "", $2)
|
||||||
|
print $2; exit
|
||||||
|
}' "$cfg"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
printf 'claude\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map backend name → executable. Mirrors scripts/kei-agent-cli.sh::backend_bin.
|
||||||
|
backend_bin_for() {
|
||||||
|
case "$1" in
|
||||||
|
claude) echo "claude" ;;
|
||||||
|
grok) echo "grok" ;;
|
||||||
|
agy|antigravity) echo "agy" ;;
|
||||||
|
copilot) echo "copilot" ;;
|
||||||
|
kimi) echo "kimi" ;;
|
||||||
|
codex) echo "codex" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
PRIMARY="$(resolve_primary)"
|
||||||
|
PRIMARY_CLI="$(backend_bin_for "$PRIMARY")" || {
|
||||||
|
echo "error: unknown primary backend: $PRIMARY" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
PRIMARY_BIN="$(command -v "$PRIMARY_CLI" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [ -z "$PRIMARY_BIN" ] && [ "$STATUS_ONLY" = "0" ]; then
|
||||||
|
echo "error: primary backend '$PRIMARY' → '$PRIMARY_CLI' not on PATH." >&2
|
||||||
|
case "$PRIMARY" in
|
||||||
|
claude) echo " install: curl -fsSL https://claude.ai/install.sh | sh" >&2 ;;
|
||||||
|
grok) echo " install: see https://x.ai/grok" >&2 ;;
|
||||||
|
copilot) echo " install: npm i -g @github/copilot" >&2 ;;
|
||||||
|
kimi) echo " install: uv tool install kimi-cli" >&2 ;;
|
||||||
|
agy) echo " install: see https://antigravity.dev" >&2 ;;
|
||||||
|
codex) echo " install: see https://github.com/openai/codex" >&2 ;;
|
||||||
|
esac
|
||||||
|
echo " or: kei pick (interactive picker to choose + set primary)" >&2
|
||||||
|
echo " or: kei primary <backend> (set a different default)" >&2
|
||||||
exit 127
|
exit 127
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Legacy var name for splash code below.
|
||||||
|
CLAUDE_BIN="$PRIMARY_BIN"
|
||||||
|
|
||||||
# --- read state ----------------------------------------------------------
|
# --- read state ----------------------------------------------------------
|
||||||
AGENTS_DIR="${HOME}/.claude/agents"
|
AGENTS_DIR="${HOME}/.claude/agents"
|
||||||
SYNC_DIR="${HOME}/.claude/memory/sync-repo"
|
SYNC_DIR="${HOME}/.claude/memory/sync-repo"
|
||||||
|
|
@ -52,12 +170,10 @@ agent_count() {
|
||||||
|
|
||||||
# Profile from .installed marker file
|
# Profile from .installed marker file
|
||||||
profile_name() {
|
profile_name() {
|
||||||
local f="${AGENTS_DIR}/_primitives/.installed"
|
# install.sh stamps the chosen profile here; .installed only holds primitive
|
||||||
if [ -f "$f" ]; then
|
# names (so the old `grep ^profile .installed` always returned "?").
|
||||||
grep -E "^profile" "$f" 2>/dev/null | head -1 | awk -F= '{gsub(/[ "]/,"",$2); print $2}' || echo "?"
|
local f="$HOME/.claude/.kei-profile"
|
||||||
else
|
if [ -f "$f" ]; then head -1 "$f"; else echo "?"; fi
|
||||||
echo "?"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Last Phase B sleep timestamp
|
# Last Phase B sleep timestamp
|
||||||
|
|
@ -100,13 +216,14 @@ splash() {
|
||||||
sl="$(last_sleep_run)"
|
sl="$(last_sleep_run)"
|
||||||
as="$(active_sessions)"
|
as="$(active_sessions)"
|
||||||
|
|
||||||
# Only color if stdout is a tty
|
# Only color if stdout is a tty. Brand palette: голубой (sky-blue) + жёлтый (gold).
|
||||||
local C0= C1= C2= C3=
|
local C0= C1= C2= C3= CV=
|
||||||
if [ -t 1 ]; then
|
if [ -t 1 ]; then
|
||||||
C0=$'\033[0m'
|
C0=$'\033[0m'
|
||||||
C1=$'\033[1;36m' # cyan-bold
|
C1=$'\033[1;38;5;39m' # голубой (sky-blue) — logo
|
||||||
C2=$'\033[0;36m' # cyan
|
C2=$'\033[1;38;5;220m' # жёлтый (gold) — brand line
|
||||||
C3=$'\033[2m' # dim
|
C3=$'\033[2;38;5;39m' # dim blue — separators
|
||||||
|
CV=$'\033[1;38;5;220m' # жёлтый — field values
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
@ -118,12 +235,13 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█
|
||||||
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
|
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
|
||||||
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
|
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
|
||||||
|
|
||||||
${C2} KeiSeiKit · substrate v0.16${C0}
|
${C2} KeiSeiKit · substrate v0.45${C0}
|
||||||
${C3} ─────────────────────────────────────${C0}
|
${C3} ─────────────────────────────────────${C0}
|
||||||
profile : ${p}
|
primary CLI : ${CV}${PRIMARY}${C0}
|
||||||
agents : ${ac}
|
profile : ${CV}${p}${C0}
|
||||||
last sleep run : ${sl}
|
agents : ${CV}${ac}${C0}
|
||||||
active sessions: ${as}
|
last sleep run : ${CV}${sl}${C0}
|
||||||
|
active sessions: ${CV}${as}${C0}
|
||||||
${C3} ─────────────────────────────────────${C0}
|
${C3} ─────────────────────────────────────${C0}
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
|
||||||
41
bootstrap.sh
41
bootstrap.sh
|
|
@ -8,7 +8,7 @@
|
||||||
#
|
#
|
||||||
# Usage from a fresh machine (private repo, gh CLI required for clone):
|
# Usage from a fresh machine (private repo, gh CLI required for clone):
|
||||||
# gh auth login
|
# gh auth login
|
||||||
# gh repo clone KeiSei84/KeiSeiKit-1.0
|
# gh repo clone KeiSeiLab/KeiSeiKit-1.0
|
||||||
# cd KeiSeiKit-1.0 && ./bootstrap.sh
|
# cd KeiSeiKit-1.0 && ./bootstrap.sh
|
||||||
#
|
#
|
||||||
# What it does (idempotent — re-running is safe):
|
# What it does (idempotent — re-running is safe):
|
||||||
|
|
@ -48,7 +48,14 @@ done
|
||||||
# fallback to cortex for compat with v0.16 default behaviour.
|
# fallback to cortex for compat with v0.16 default behaviour.
|
||||||
prompt_profile() {
|
prompt_profile() {
|
||||||
if [ -n "$PROFILE" ]; then return 0; fi
|
if [ -n "$PROFILE" ]; then return 0; fi
|
||||||
if [ ! -t 0 ] || [ ! -t 1 ]; then PROFILE="cortex"; return 0; fi
|
# Interactive iff stdin is a terminal. NOT stdout: web-install.sh tees stdout
|
||||||
|
# to a logfile (pipe), so -t 1 is false even in an interactive curl|bash.
|
||||||
|
# Prompts print to the terminal via tee; the menu reads from stdin.
|
||||||
|
# Non-interactive (CI / piped, no controlling terminal) → minimal: fast,
|
||||||
|
# no 105-crate compile, can't half-fail. Matches install.sh's own default
|
||||||
|
# (was "cortex" here → divergent install vs direct install.sh). Opt up with
|
||||||
|
# --profile=cortex/full-hub.
|
||||||
|
if [ ! -t 0 ]; then PROFILE="minimal"; return 0; fi
|
||||||
cat <<'WIZARD'
|
cat <<'WIZARD'
|
||||||
|
|
||||||
╔═══════════════════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════════════════╗
|
||||||
|
|
@ -164,15 +171,20 @@ fi
|
||||||
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
if [ ! -f "$KIT_DIR/install.sh" ]; then
|
if [ ! -f "$KIT_DIR/install.sh" ]; then
|
||||||
err "install.sh not found in $KIT_DIR — am I inside a KeiSeiKit checkout?"
|
err "install.sh not found in $KIT_DIR — am I inside a KeiSeiKit checkout?"
|
||||||
err "if not: gh repo clone KeiSei84/KeiSeiKit-1.0 && cd KeiSeiKit-1.0 && ./bootstrap.sh"
|
err "if not: gh repo clone KeiSeiLab/KeiSeiKit-1.0 && cd KeiSeiKit-1.0 && ./bootstrap.sh"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
log "checkout: $KIT_DIR"
|
log "checkout: $KIT_DIR"
|
||||||
|
|
||||||
# --- 5. run install ------------------------------------------------------
|
# --- 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"
|
cd "$KIT_DIR"
|
||||||
./install.sh --profile="$PROFILE" $YES_FLAG "${EXTRA_FLAGS[@]:+${EXTRA_FLAGS[@]}}"
|
# 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[@]}}"
|
||||||
|
|
||||||
# --- 6. post-install verification ----------------------------------------
|
# --- 6. post-install verification ----------------------------------------
|
||||||
KEI_BIN="$HOME/.claude/agents/_primitives/_rust/target/release"
|
KEI_BIN="$HOME/.claude/agents/_primitives/_rust/target/release"
|
||||||
|
|
@ -192,6 +204,25 @@ log ""
|
||||||
log "==========================================================================="
|
log "==========================================================================="
|
||||||
log "DONE — KeiSeiKit installed (profile: $PROFILE)"
|
log "DONE — KeiSeiKit installed (profile: $PROFILE)"
|
||||||
log "==========================================================================="
|
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 ""
|
||||||
log "Next steps:"
|
log "Next steps:"
|
||||||
log " - Open a new shell so PATH picks up ~/.cargo/bin and the kei-* binaries."
|
log " - Open a new shell so PATH picks up ~/.cargo/bin and the kei-* binaries."
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ cd _primitives/_rust && cargo test --workspace 2>&1 | tail -5
|
||||||
|
|
||||||
## GitHub Releases status
|
## GitHub Releases status
|
||||||
|
|
||||||
6 tags pushed. Release workflow in `release.yml` triggers on tag push. Expected: 6 releases × (3 Rust tarballs + 5 MCP binaries + sha256 each) = ~48 assets. Check `github.com/KeiSei84/KeiSeiKit/releases` on wake — all tags should have attached assets within 10 min of push per prior v0.22.3 smoke. CI was re-triggered after Pro upgrade; confirm status there.
|
6 tags pushed. Release workflow in `release.yml` triggers on tag push. Expected: 6 releases × (3 Rust tarballs + 5 MCP binaries + sha256 each) = ~48 assets. Check `github.com/KeiSeiLab/KeiSeiKit-1.0/releases` on wake — all tags should have attached assets within 10 min of push per prior v0.22.3 smoke. CI was re-triggered after Pro upgrade; confirm status there.
|
||||||
|
|
||||||
## What substrate looks like now
|
## What substrate looks like now
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ Complete install guide. Quick-start lives in the main [README](../README.md#inst
|
||||||
|
|
||||||
| Path | Command | Best for |
|
| Path | Command | Best for |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Plugin** (v0.16+, recommended on Claude Code 2.1+) | `/plugin marketplace add KeiSei84/KeiSeiKit` then `/plugin install keisei@keisei-marketplace` | Agents + skills + hooks + MCP. Zero cargo build. See [PLUGIN.md](../PLUGIN.md). |
|
| **Plugin** (v0.16+, recommended on Claude Code 2.1+) | `/plugin marketplace add KeiSeiLab/KeiSeiKit-1.0` then `/plugin install keisei@keisei-marketplace` | Agents + skills + hooks + MCP. Zero cargo build. See [PLUGIN.md](../PLUGIN.md). |
|
||||||
| **Classic** `./install.sh` | Below | Full kit incl. 47 Rust primitives + 13 shell primitives. Required for `ops` / `dev` / `full` profiles. |
|
| **Classic** `./install.sh` | Below | Full kit incl. 47 Rust primitives + 13 shell primitives. Required for `ops` / `dev` / `full` profiles. |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ mkdir -p "$BRAIN"/{bin,memory,artifacts,manifests}
|
||||||
## 2. Download MCP server binaries
|
## 2. Download MCP server binaries
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE=https://github.com/KeiSei84/KeiSeiKit/releases/download/v0.21.0
|
BASE=https://github.com/KeiSeiLab/KeiSeiKit-1.0/releases/download/v0.21.0
|
||||||
cd "$BRAIN/bin"
|
cd "$BRAIN/bin"
|
||||||
for n in darwin-arm64 darwin-x64 linux-x64 linux-arm64 windows-x64.exe; do
|
for n in darwin-arm64 darwin-x64 linux-x64 linux-arm64 windows-x64.exe; do
|
||||||
curl -fL -O "$BASE/kei-mcp-server-$n" 2>/dev/null || echo "skipped $n"
|
curl -fL -O "$BASE/kei-mcp-server-$n" 2>/dev/null || echo "skipped $n"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ mkdir -p "$BRAIN"/{bin,memory,artifacts,manifests}
|
||||||
## 2. Download MCP server binaries
|
## 2. Download MCP server binaries
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE=https://github.com/KeiSei84/KeiSeiKit/releases/download/v0.21.0
|
BASE=https://github.com/KeiSeiLab/KeiSeiKit-1.0/releases/download/v0.21.0
|
||||||
cd "$BRAIN/bin"
|
cd "$BRAIN/bin"
|
||||||
for n in darwin-arm64 darwin-x64 linux-x64 windows-x64.exe; do
|
for n in darwin-arm64 darwin-x64 linux-x64 windows-x64.exe; do
|
||||||
curl -fL -O "$BASE/kei-mcp-server-$n"
|
curl -fL -O "$BASE/kei-mcp-server-$n"
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ New-Item -ItemType Directory -Path $BRAIN,"$BRAIN\bin","$BRAIN\memory","$BRAIN\a
|
||||||
## 2. Download MCP server binaries
|
## 2. Download MCP server binaries
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$BASE = "https://github.com/KeiSei84/KeiSeiKit/releases/download/v0.21.0"
|
$BASE = "https://github.com/KeiSeiLab/KeiSeiKit-1.0/releases/download/v0.21.0"
|
||||||
Push-Location "$BRAIN\bin"
|
Push-Location "$BRAIN\bin"
|
||||||
|
|
||||||
$names = @(
|
$names = @(
|
||||||
|
|
|
||||||
178
docs/encyclopedia/cross-cli-policy.md
Normal file
178
docs/encyclopedia/cross-cli-policy.md
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
# 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`
|
||||||
|
|
@ -28,12 +28,23 @@ All hooks live under `hooks/` directory. Format: `| Hook Name | Event | Severity
|
||||||
- **remind (exit 0 + stderr on trigger)** — passive reminder
|
- **remind (exit 0 + stderr on trigger)** — passive reminder
|
||||||
- **advisory** — informational, never blocks
|
- **advisory** — informational, never blocks
|
||||||
|
|
||||||
|
### Hook packs (opt-in posture)
|
||||||
|
|
||||||
|
A fresh install activates **only the `safety` pack** (plus cosmetic/infra hooks).
|
||||||
|
Discipline packs are opt-in, chosen during onboarding (step 6) or later via
|
||||||
|
`kei configure`. SSoT for pack membership + stack profiles is
|
||||||
|
`_primitives/hook-packs.toml`. Packs: `safety` (always on), `evidence`,
|
||||||
|
`observability`, `epistemic`, `orchestration`, `git-guard` (opt-in only),
|
||||||
|
`stack-rust` (only under the `systems` stack profile). Discipline hooks also
|
||||||
|
respect runtime toggling via `KEI_DISABLED_HOOKS` / `KEI_HOOK_PROFILE` (see the
|
||||||
|
`hooks-control` skill).
|
||||||
|
|
||||||
### Core Safety Hooks
|
### Core Safety Hooks
|
||||||
|
|
||||||
| Hook | Event | Severity | Purpose | Bypass Env |
|
| Hook | Event | Severity | Purpose | Bypass Env |
|
||||||
|------|-------|----------|---------|-----------|
|
|------|-------|----------|---------|-----------|
|
||||||
| no-github-push.sh | PreToolUse:Bash | block | Prevent pushing KeiTech patent IP to github.com — destroys priority date | KEI_NO_GITHUB_PUSH_BYPASS |
|
| no-github-push.sh | PreToolUse:Bash | block | Block accidental push / repo-create to github.com (opt-in; for code kept on a private remote) | KEI_NO_GITHUB_PUSH_BYPASS |
|
||||||
| no-python-without-approval.sh | PreToolUse:Bash | block | Enforce RULE 0.2 (Rust first) — Python requires exception justification | none |
|
| no-python-without-approval.sh | PreToolUse:Bash | block | Optional Rust-first policy — Python requires explicit justification (opt-in, stack-gated) | none |
|
||||||
| rust-first.sh | UserPromptSubmit | remind | Remind about Rust-first default for new work | none |
|
| rust-first.sh | UserPromptSubmit | remind | Remind about Rust-first default for new work | none |
|
||||||
| secrets-pre-guard.sh | PreToolUse:Edit\|Write | block | Detect hardcoded API keys, tokens, private keys before commit | KEI_SECRETS_GUARD_BYPASS |
|
| secrets-pre-guard.sh | PreToolUse:Edit\|Write | block | Detect hardcoded API keys, tokens, private keys before commit | KEI_SECRETS_GUARD_BYPASS |
|
||||||
| destructive-guard.sh | PreToolUse:Bash | block | Block dangerous commands (rm -rf /, git reset --hard main, truncate) | none |
|
| destructive-guard.sh | PreToolUse:Bash | block | Block dangerous commands (rm -rf /, git reset --hard main, truncate) | none |
|
||||||
|
|
|
||||||
225
docs/encyclopedia/multi-cli-agents.md
Normal file
225
docs/encyclopedia/multi-cli-agents.md
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
# 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).
|
||||||
32
hooks/_lib/policy-chain.toml
Normal file
32
hooks/_lib/policy-chain.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# 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,4 +65,14 @@ if [ -n "$TOOL_USE_ID" ] && [ -f "$ACTIVE_FILE" ]; then
|
||||||
mv "$ACTIVE_FILE.tmp" "$ACTIVE_FILE" 2>/dev/null || true
|
mv "$ACTIVE_FILE.tmp" "$ACTIVE_FILE" 2>/dev/null || true
|
||||||
fi
|
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
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
|
||||||
|
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "alignment-check" || exit 0; fi
|
||||||
# ALIGNMENT CHECK HOOK
|
# ALIGNMENT CHECK HOOK
|
||||||
# Fires on UserPromptSubmit when comparison/experiment keywords detected.
|
# Fires on UserPromptSubmit when comparison/experiment keywords detected.
|
||||||
# THREE-TIME REPEAT BUG: exp6, exp24-28, basecaller — all forgot alignment.
|
# THREE-TIME REPEAT BUG: exp6, exp24-28, basecaller — all forgot alignment.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
|
||||||
|
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "chat-numeric-postflag" || exit 0; fi
|
||||||
# chat-numeric-postflag.sh — Stop warn (RULE 0.18 chat-output)
|
# chat-numeric-postflag.sh — Stop warn (RULE 0.18 chat-output)
|
||||||
#
|
#
|
||||||
# Reads the session transcript, extracts the last assistant message,
|
# Reads the session transcript, extracts the last assistant message,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
|
||||||
|
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "chat-numeric-prewarn" || exit 0; fi
|
||||||
# chat-numeric-prewarn.sh — UserPromptSubmit remind (RULE 0.18 chat-output)
|
# chat-numeric-prewarn.sh — UserPromptSubmit remind (RULE 0.18 chat-output)
|
||||||
#
|
#
|
||||||
# Detects time/cost/effort keywords in the user's prompt and injects an
|
# Detects time/cost/effort keywords in the user's prompt and injects an
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# DELETED — 2026-05-02
|
# DELETED — 2026-05-02
|
||||||
# Reasons:
|
# Reasons:
|
||||||
# 1. Hardcoded path leak: /Users/denis/projects/ai machine learning/error-patterns.json
|
# 1. Hardcoded absolute path leak (machine-specific, author-local)
|
||||||
# 2. RULE 0.2 violation: used python3 for JSON parsing
|
# 2. Language-policy violation: used python3 for JSON parsing
|
||||||
# 3. No-op on every machine except original author's
|
# 3. No-op on every machine except the original author's
|
||||||
# Removed from settings-snippet.json PostToolUse matcher "*" block.
|
# Removed from settings-snippet.json PostToolUse matcher "*" block.
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
|
||||||
|
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "citation-verify" || exit 0; fi
|
||||||
# PreToolUse(Edit|Write) — block unverified academic citations
|
# PreToolUse(Edit|Write) — block unverified academic citations
|
||||||
#
|
#
|
||||||
# Rule 0.5 NO HALLUCINATION enforcer.
|
# Rule 0.5 NO HALLUCINATION enforcer.
|
||||||
|
|
|
||||||
41
hooks/first-run-onboard.sh
Executable file
41
hooks/first-run-onboard.sh
Executable file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# first-run-onboard — on the FIRST Claude Code session after a KeiSeiKit install,
|
||||||
|
# inject a one-time POST-INSTALL ONBOARDING checklist so Claude walks the user
|
||||||
|
# through ALL setup in order (agents → sleep → cortex), instead of leaving each
|
||||||
|
# as a separate thing the user has to discover. Event: SessionStart (stdout is
|
||||||
|
# injected into session context). Fires ONCE (marker), then silent forever.
|
||||||
|
# Reset / re-run: rm ~/.claude/.kei-firstrun-shown
|
||||||
|
|
||||||
|
MARKER="$HOME/.claude/.kei-firstrun-shown"
|
||||||
|
[ -f "$MARKER" ] && exit 0
|
||||||
|
[ -d "$HOME/.claude/agents" ] || exit 0
|
||||||
|
|
||||||
|
PROJ="$HOME/Projects"
|
||||||
|
[ -d "$PROJ" ] || PROJ="$HOME/projects"
|
||||||
|
[ -d "$PROJ" ] || PROJ="$HOME/Projects"
|
||||||
|
|
||||||
|
# Cortex step only if the cortex daemon primitive landed (cortex / full* profiles).
|
||||||
|
CORTEX_STEP=""
|
||||||
|
if [ -d "$HOME/.claude/agents/_primitives/_rust/kei-cortex" ] \
|
||||||
|
|| [ -x "$HOME/.claude/agents/_primitives/_rust/target/release/kei-cortex" ]; then
|
||||||
|
CORTEX_STEP=" 3. /cortex-setup — cortex daemon + UI (token, whisper, model, bundle)
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
[KeiSeiKit · FIRST-RUN ONBOARDING] The substrate is installed. Before other
|
||||||
|
work, proactively walk the user through this one-time setup, step by step, in
|
||||||
|
order — confirm each step with the user, then run it:
|
||||||
|
|
||||||
|
1. /onboard $PROJ/* — scan every project, detect stack, create a
|
||||||
|
project-specialist agent per project (delegates to /new-agent).
|
||||||
|
2. /sleep-setup — nightly memory (REM) consolidation. Recommend
|
||||||
|
local-only mode (runs on this Mac, no remote/cloud git needed) unless the
|
||||||
|
user wants the cloud agent (needs a cloud-reachable private git repo).
|
||||||
|
$CORTEX_STEP
|
||||||
|
Start with step 1. Offer to do them one after another as a guided flow; the user
|
||||||
|
can skip any. Do NOT make the user discover these on their own.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
: > "$MARKER"
|
||||||
|
exit 0
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
# Bypass: GRAPH_EXPORT_BYPASS=1
|
# Bypass: GRAPH_EXPORT_BYPASS=1
|
||||||
|
|
||||||
INTERVAL="${KEI_GRAPH_EXPORT_INTERVAL_S:-5}"
|
INTERVAL="${KEI_GRAPH_EXPORT_INTERVAL_S:-5}"
|
||||||
OUT="${KEI_GRAPH_VIZ_DIR:-$HOME/Projects/lbm-graph-viz}/data-runtime.js"
|
OUT="${KEI_GRAPH_VIZ_DIR:-$HOME/.local/share/kei/graph-viz}/data-runtime.js"
|
||||||
BIN="$(command -v kei-graph-export 2>/dev/null || echo "$HOME/.cargo/bin/kei-graph-export")"
|
BIN="$(command -v kei-graph-export 2>/dev/null || echo "$HOME/.cargo/bin/kei-graph-export")"
|
||||||
|
|
||||||
[ -x "$BIN" ] || exit 0
|
[ -x "$BIN" ] || exit 0
|
||||||
|
|
|
||||||
57
hooks/mailbox-inject.sh
Executable file
57
hooks/mailbox-inject.sh
Executable file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# mailbox-inject — pull-inbox for kei-message. On every UserPromptSubmit, inject
|
||||||
|
# any messages addressed to THIS session (by cwd-basename or the broadcast
|
||||||
|
# channel "all") that arrived since last turn, into the session context, so
|
||||||
|
# Claude sees what other sessions sent. Per-session read cursor dedups; first
|
||||||
|
# turn starts fresh (no history dump). Never blocks (always exit 0).
|
||||||
|
# Event: UserPromptSubmit. Bypass: KEI_MAILBOX_BYPASS=1.
|
||||||
|
|
||||||
|
[ "${KEI_MAILBOX_BYPASS:-}" = "1" ] && exit 0
|
||||||
|
command -v jq >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
INPUT=$(cat)
|
||||||
|
SID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
|
||||||
|
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
|
||||||
|
[ -n "$CWD" ] || CWD="$PWD"
|
||||||
|
me="$(basename "$CWD")"
|
||||||
|
|
||||||
|
MBOX="$HOME/.claude/mailbox"
|
||||||
|
LOG="$MBOX/messages.jsonl"
|
||||||
|
mkdir -p "$MBOX"
|
||||||
|
CUR="$MBOX/.cursor-${SID:-$me}"
|
||||||
|
|
||||||
|
# Highest id currently in the bus (0 if the log doesn't exist yet / is empty).
|
||||||
|
if [ -f "$LOG" ]; then
|
||||||
|
maxid=$(jq -s 'map(.id) | max // 0' "$LOG" 2>/dev/null || echo 0)
|
||||||
|
else
|
||||||
|
maxid=0
|
||||||
|
fi
|
||||||
|
[ -n "$maxid" ] || maxid=0
|
||||||
|
|
||||||
|
# First fire for this session: record baseline cursor, show nothing. Done even
|
||||||
|
# when the bus is still empty — so messages that arrive AFTER this point (but
|
||||||
|
# before the session's next turn) are not missed.
|
||||||
|
if [ ! -f "$CUR" ]; then
|
||||||
|
echo "$maxid" > "$CUR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Nothing to read yet.
|
||||||
|
[ -f "$LOG" ] || { echo "$maxid" > "$CUR"; exit 0; }
|
||||||
|
|
||||||
|
last=$(cat "$CUR" 2>/dev/null || echo 0)
|
||||||
|
case "$last" in ''|*[!0-9]*) last=0 ;; esac
|
||||||
|
|
||||||
|
new=$(jq -r --argjson last "$last" --arg me "$me" '
|
||||||
|
select(.id > $last)
|
||||||
|
| select(.to == $me or .to == "all")
|
||||||
|
| select(.from != $me)
|
||||||
|
| " • \(.from) -> \(.to): \(.body)"' "$LOG" 2>/dev/null)
|
||||||
|
|
||||||
|
# Advance cursor past everything seen this turn.
|
||||||
|
echo "$maxid" > "$CUR"
|
||||||
|
|
||||||
|
if [ -n "$new" ]; then
|
||||||
|
printf '[kei mailbox] new message(s) for this session (%s):\n%s\n (reply: kei message send --to <name> "...")\n' "$me" "$new"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
|
||||||
|
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "no-downgrade" || exit 0; fi
|
||||||
# RULE -1 NO DOWNGRADE / CONSTRUCTIVE ONLY (2026-04-15 LOCK) enforcement.
|
# RULE -1 NO DOWNGRADE / CONSTRUCTIVE ONLY (2026-04-15 LOCK) enforcement.
|
||||||
#
|
#
|
||||||
# Detects downgrade-style phrases in Write/Edit content without accompanying
|
# Detects downgrade-style phrases in Write/Edit content without accompanying
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# no-github-push.sh — PreToolUse:Bash hard deny (RULE 0.1 NO GITHUB PUSH)
|
# no-github-push.sh — PreToolUse:Bash hard deny.
|
||||||
#
|
#
|
||||||
# Blocks any Bash command that would push code to github.com.
|
# Blocks any Bash command that would push code or create a repo on github.com.
|
||||||
# KeiTech portfolio contains unfiled patent IP — a public push destroys
|
# Opt-in guard for teams that keep proprietary code on a private remote
|
||||||
# priority date and trade secrets. Irrecoverable.
|
# (Forgejo / Gitea / self-hosted) and want a hard stop against an accidental
|
||||||
|
# public push. Off by default in the public kit — enable it in onboarding.
|
||||||
#
|
#
|
||||||
# Exit codes:
|
# Exit codes:
|
||||||
# 0 = pass (command is safe)
|
# 0 = pass (command is safe)
|
||||||
|
|
@ -69,18 +70,16 @@ fi
|
||||||
|
|
||||||
# --- Block ------------------------------------------------------------------
|
# --- Block ------------------------------------------------------------------
|
||||||
cat >&2 <<'EOF'
|
cat >&2 <<'EOF'
|
||||||
[no-github-push] BLOCK — RULE 0.1 NO GITHUB PUSH
|
[no-github-push] BLOCK — push to github.com is disabled by this guard.
|
||||||
KeiTech portfolio contains unfiled patent IP. Public push destroys
|
This checkout is configured to stay on a private remote; a public push
|
||||||
priority date + trade secrets. Irrecoverable.
|
could expose code you intend to keep private.
|
||||||
|
|
||||||
Use a private remote instead (Forgejo, Gitea, self-hosted):
|
Use your private remote instead (Forgejo, Gitea, self-hosted):
|
||||||
git remote set-url origin ssh://git@<private-host>/<user>/<repo>.git
|
git remote set-url origin ssh://git@<private-host>/<user>/<repo>.git
|
||||||
git push origin <branch>
|
git push origin <branch>
|
||||||
|
|
||||||
Bypass (visible, per-call):
|
Bypass (visible, per-call):
|
||||||
Set env KEI_NO_GITHUB_PUSH_BYPASS=1 before the command.
|
Set env KEI_NO_GITHUB_PUSH_BYPASS=1 before the command.
|
||||||
You must also add confirmation phrase: "yes, push patent code to github"
|
|
||||||
+ "confirm publication" in the session turn.
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
exit 2
|
exit 2
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
|
||||||
|
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "no-python-without-approval" || exit 0; fi
|
||||||
# Hard block on python/python3/python2 invocations in Bash tool.
|
# Hard block on python/python3/python2 invocations in Bash tool.
|
||||||
# RULE 0.2 (Rust First) — Python requires explicit architectural reason.
|
# RULE 0.2 (Rust First) — Python requires explicit architectural reason.
|
||||||
# Claude кroнически нарушает RULE 0.2 inline-вызовами python3 для мелких расчётов.
|
# Claude кroнически нарушает RULE 0.2 inline-вызовами python3 для мелких расчётов.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
|
||||||
|
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "numeric-claims-guard" || exit 0; fi
|
||||||
# RULE 0.18 — Numeric claim enforcement — block Edit/Write of numeric claims
|
# RULE 0.18 — Numeric claim enforcement — block Edit/Write of numeric claims
|
||||||
# without evidence marker. Bypass: RULE_017_BYPASS=1 prefix (kept for compat).
|
# without evidence marker. Bypass: RULE_017_BYPASS=1 prefix (kept for compat).
|
||||||
#
|
#
|
||||||
|
|
@ -26,7 +29,7 @@ fi
|
||||||
# - "N MB/GB/LOC/tests/crates/atomars"
|
# - "N MB/GB/LOC/tests/crates/atomars"
|
||||||
# - "~$N", "$N/mo"
|
# - "~$N", "$N/mo"
|
||||||
# - "Nm Ns", "займёт N", "should take N"
|
# - "Nm Ns", "займёт N", "should take N"
|
||||||
NUMERIC_PATTERN='(~\s*[0-9]+(\.[0-9]+)?\s*(min|minute|hour|hr|day|week|month|sec|second|MB|GB|KB|LOC|line|test|crate|atomar|%|µs|ms|ns|TPS|req/s)|[0-9]+m\s*[0-9]+s|\$[0-9]+(\.[0-9]+)?(/(mo|hr|day|run))?|~\s*\$[0-9]+|should take|will take|takes about|займёт|за ~|estimated at|ETA[: ]|approximately\s+[0-9])'
|
NUMERIC_PATTERN='(~\s*[0-9]+(\.[0-9]+)?\s*(min|minute|hour|hr|day|week|month|sec|second|MB|GB|KB|LOC|line|test|crate|atomar|%|µs|ms|ns|TPS|req/s)|[0-9]+m\s*[0-9]+s|\$[0-9]+\.[0-9]+|\$[0-9]+/(mo|hr|day|run)|\$[0-9]{2,}|~\s*\$[0-9]+|should take|will take|takes about|займёт|за ~|estimated at|ETA[: ]|approximately\s+[0-9])'
|
||||||
|
|
||||||
# Markers that satisfy the rule
|
# Markers that satisfy the rule
|
||||||
EVIDENCE_PATTERN='\[(REAL|FROM-JOURNAL|ESTIMATE-HTC)[: ]'
|
EVIDENCE_PATTERN='\[(REAL|FROM-JOURNAL|ESTIMATE-HTC)[: ]'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
|
||||||
|
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "rust-first" || exit 0; fi
|
||||||
# RULE 0.2 — RUST FIRST reminder hook.
|
# RULE 0.2 — RUST FIRST reminder hook.
|
||||||
#
|
#
|
||||||
# Fires on UserPromptSubmit. Detects keywords indicating language choice
|
# Fires on UserPromptSubmit. Detects keywords indicating language choice
|
||||||
|
|
|
||||||
|
|
@ -36,36 +36,67 @@ if [ -n "$transcript" ] && [ -f "$transcript" ]; then
|
||||||
cp -f "$transcript" "$dest" 2>/dev/null || true
|
cp -f "$transcript" "$dest" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Best-effort ingest — advisory only; never blocks the session from ending.
|
# 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.
|
||||||
if command -v kei-memory >/dev/null 2>&1 && [ -f "$dest" ]; then
|
if command -v kei-memory >/dev/null 2>&1 && [ -f "$dest" ]; then
|
||||||
kei-memory ingest \
|
(
|
||||||
--session-id "$session_id" \
|
kei_with_timeout 90 kei-memory ingest \
|
||||||
--transcript "$dest" \
|
--session-id "$session_id" \
|
||||||
>/dev/null 2>&1 || true
|
--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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Wave 25 — frustration-matrix scan: regex+firmware classifier produces a
|
# Wave 25 — frustration-matrix scan.
|
||||||
# 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
|
if command -v frustration-matrix >/dev/null 2>&1; then
|
||||||
affect_dir="${HOME}/.claude/memory/affect"
|
affect_dir="${HOME}/.claude/memory/affect"
|
||||||
mkdir -p "$affect_dir" 2>/dev/null || true
|
mkdir -p "$affect_dir" 2>/dev/null || true
|
||||||
affect_out="${affect_dir}/${session_id}.jsonl"
|
affect_out="${affect_dir}/${session_id}.jsonl"
|
||||||
frustration-matrix scan \
|
(
|
||||||
--root "$traces_dir" \
|
kei_with_timeout 60 frustration-matrix scan \
|
||||||
--since 1d \
|
--root "$traces_dir" \
|
||||||
--format jsonl \
|
--since 1d \
|
||||||
--output "$affect_out" \
|
--format jsonl \
|
||||||
>/dev/null 2>&1 || true
|
--output "$affect_out" \
|
||||||
|
>>"$bg_log" 2>&1 || true
|
||||||
|
) </dev/null >/dev/null 2>&1 &
|
||||||
|
disown 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# v0.11 sleep-sync (RULE 0.15) — push traces to the user's memory-repo so a
|
# v0.11 sleep-sync (RULE 0.15) — push traces to memory-repo.
|
||||||
# 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"
|
sleep_sync="${HOME}/.claude/agents/_primitives/kei-sleep-sync.sh"
|
||||||
if [ -x "$sleep_sync" ]; then
|
if [ -x "$sleep_sync" ]; then
|
||||||
"$sleep_sync" >/dev/null 2>&1 || true
|
(
|
||||||
|
kei_with_timeout 120 "$sleep_sync" >>"$bg_log" 2>&1 || true
|
||||||
|
) </dev/null >/dev/null 2>&1 &
|
||||||
|
disown 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
34
install.sh
34
install.sh
|
|
@ -37,6 +37,8 @@ source "$LIB_DIR/lib-log.sh"
|
||||||
source "$LIB_DIR/lib-backup.sh"
|
source "$LIB_DIR/lib-backup.sh"
|
||||||
# shellcheck source=install/lib-profile.sh
|
# shellcheck source=install/lib-profile.sh
|
||||||
source "$LIB_DIR/lib-profile.sh"
|
source "$LIB_DIR/lib-profile.sh"
|
||||||
|
# shellcheck source=install/lib-packs.sh
|
||||||
|
source "$LIB_DIR/lib-packs.sh"
|
||||||
# shellcheck source=install/lib-args.sh
|
# shellcheck source=install/lib-args.sh
|
||||||
source "$LIB_DIR/lib-args.sh"
|
source "$LIB_DIR/lib-args.sh"
|
||||||
# shellcheck source=install/lib-menu.sh
|
# shellcheck source=install/lib-menu.sh
|
||||||
|
|
@ -147,6 +149,31 @@ case "$PROFILE" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
say "profile: $PROFILE"
|
say "profile: $PROFILE"
|
||||||
|
# Stamp the chosen profile so `kei` splash + tools can show it (bin/kei reads this).
|
||||||
|
mkdir -p "$HOME_DIR/.claude" 2>/dev/null || true
|
||||||
|
printf '%s\n' "$PROFILE" > "$HOME_DIR/.claude/.kei-profile" 2>/dev/null || true
|
||||||
|
# Stamp the kit checkout dir so `kei configure` can re-source the libs later.
|
||||||
|
printf '%s\n' "$KIT_DIR" > "$HOME_DIR/.claude/.kei-kit-dir" 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- resolve profile -> primitive list (UNCONDITIONAL, SSoT) -------------
|
||||||
|
# Must run BEFORE any reader of PROFILE_PRIMS: the --no-execute plan block
|
||||||
|
# below, the --skip-prereqs path (which bypasses check_prereqs), and the
|
||||||
|
# conditional cargo gate in check_hard_prereqs. Previously this lived only
|
||||||
|
# inside check_prereqs, so --no-execute / --skip-prereqs saw PROFILE_PRIMS
|
||||||
|
# unbound and silently resolved to 0 primitives.
|
||||||
|
resolve_profile_prims
|
||||||
|
|
||||||
|
# --- skip heavy substrate workspace build for no-rust-primitive profiles --
|
||||||
|
# The agent assembler always compiles (tiny), but the 105-crate substrate
|
||||||
|
# workspace (kei-fork / kei-ledger / kei-cortex / ...) only matters for
|
||||||
|
# profiles that ship rust primitives. With no prebuilt release binaries,
|
||||||
|
# every install would otherwise fall back to a 5-15 min `cargo build
|
||||||
|
# --workspace`. Auto-skip keeps minimal / shell-only profiles fast. An
|
||||||
|
# explicit user-set KEI_SKIP_RUST always wins.
|
||||||
|
if [ -z "${KEI_SKIP_RUST:-}" ] && ! _profile_needs_cargo; then
|
||||||
|
export KEI_SKIP_RUST=1
|
||||||
|
say "no rust primitives in profile=$PROFILE -> skipping substrate workspace build (assembler only)"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- welcome banner + onboarding wizard ----------------------------------
|
# --- welcome banner + onboarding wizard ----------------------------------
|
||||||
# Banner всегда EN — пользователь ещё не выбрал язык.
|
# Banner всегда EN — пользователь ещё не выбрал язык.
|
||||||
|
|
@ -183,6 +210,7 @@ if ! printf '%s\n' "$CONFIRM_INPUT" | show_confirm_screen "$CONFIRM_LABEL"; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- execute install phases ----------------------------------------------
|
# --- execute install phases ----------------------------------------------
|
||||||
|
kei_banner
|
||||||
setup_target_dirs
|
setup_target_dirs
|
||||||
scaffold_memory_index
|
scaffold_memory_index
|
||||||
install_blocks
|
install_blocks
|
||||||
|
|
@ -213,7 +241,11 @@ fi
|
||||||
# target/release/ regardless of profile (lib-substrate.sh), so PATH wiring
|
# target/release/ regardless of profile (lib-substrate.sh), so PATH wiring
|
||||||
# is meaningful for every profile except minimal-without-prebuilt.
|
# is meaningful for every profile except minimal-without-prebuilt.
|
||||||
if [ "$NO_PATHWAY" != "1" ]; then
|
if [ "$NO_PATHWAY" != "1" ]; then
|
||||||
if [ "$WITH_PATHWAY" = "1" ] || { [ -t 0 ] && [ -t 1 ]; }; then
|
# Gate on interactive stdin only — NOT -t 1: curl|bash tees stdout to a
|
||||||
|
# logfile, so -t 1 is false even interactively. Requiring it skipped PATH
|
||||||
|
# wiring (~/.claude/bin), so the `kei` entry-point was not found after a
|
||||||
|
# curl|bash install. (Same tee/-t1 trap as the onboarding gates.)
|
||||||
|
if [ "$WITH_PATHWAY" = "1" ] || [ -t 0 ]; then
|
||||||
pathway_install
|
pathway_install
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ STR_WELCOME_TITLE="KeiSeiKit · Exobrain installer"
|
||||||
STR_WELCOME_TAGLINE="Portable Rust agent substrate for AI coding tools"
|
STR_WELCOME_TAGLINE="Portable Rust agent substrate for AI coding tools"
|
||||||
|
|
||||||
# Onboarding wizard steps
|
# Onboarding wizard steps
|
||||||
STR_ONBOARDING_INTRO="Onboarding wizard (5 steps)"
|
STR_ONBOARDING_INTRO="Onboarding wizard (6 steps)"
|
||||||
STR_PICK_LANGUAGE="Choose interface language:"
|
STR_PICK_LANGUAGE="Choose interface language:"
|
||||||
STR_PICK_TRANSPORT="Choose connection transport:"
|
STR_PICK_TRANSPORT="Choose connection transport:"
|
||||||
STR_PICK_PROVIDER="Choose provider within"
|
STR_PICK_PROVIDER="Choose provider within"
|
||||||
|
|
@ -19,7 +19,7 @@ STR_TR_AZURE_OPENAI="Azure OpenAI (deployment+key)"
|
||||||
STR_TR_GOOGLE_VERTEX="Google Vertex AI (GCP)"
|
STR_TR_GOOGLE_VERTEX="Google Vertex AI (GCP)"
|
||||||
STR_TR_LOCAL="Local (Ollama/MLX/LMStudio)"
|
STR_TR_LOCAL="Local (Ollama/MLX/LMStudio)"
|
||||||
STR_TR_PROXY="Proxy (LiteLLM/OpenRouter)"
|
STR_TR_PROXY="Proxy (LiteLLM/OpenRouter)"
|
||||||
STR_TR_SUBSCRIPTION="OAuth subscription (ChatGPT)"
|
STR_TR_SUBSCRIPTION="Subscription login (Claude Code / ChatGPT — no API key)"
|
||||||
|
|
||||||
# Auth collection
|
# Auth collection
|
||||||
STR_AUTH_INTRO="Auth for"
|
STR_AUTH_INTRO="Auth for"
|
||||||
|
|
@ -40,3 +40,25 @@ STR_MENU_CONFIRM="Confirm selection?"
|
||||||
# Preflight warnings
|
# Preflight warnings
|
||||||
STR_PREFLIGHT_FAILED="Preflight failed — provider may not work."
|
STR_PREFLIGHT_FAILED="Preflight failed — provider may not work."
|
||||||
STR_PREFLIGHT_CONTINUE="Continue anyway? [y/N]"
|
STR_PREFLIGHT_CONTINUE="Continue anyway? [y/N]"
|
||||||
|
|
||||||
|
# Wizard explanations + input validation
|
||||||
|
STR_PICK_INVALID="please type one of the numbers shown"
|
||||||
|
STR_EXPLAIN_TRANSPORT="How the agents reach the AI. subscription = log in with your plan, no API key (Claude Code is option 1); direct-api = your own API key. Press Enter for the default."
|
||||||
|
STR_EXPLAIN_PROVIDER="Which AI service. Option 1 is the recommended default — press Enter."
|
||||||
|
STR_EXPLAIN_MODEL="Default model the agents use. Option 1 is the recommended default — press Enter."
|
||||||
|
|
||||||
|
# Stack profile + hook-pack picker (step 6)
|
||||||
|
STR_PICK_STACK="Pick your stack profile (selects which hooks + agents install):"
|
||||||
|
STR_PICK_STACK_PROMPT="[1-5, default 1=minimal]: "
|
||||||
|
STR_STACK_MINIMAL="safety hooks + core agents only"
|
||||||
|
STR_STACK_WEB="TS/frontend agents + evidence, observability"
|
||||||
|
STR_STACK_ML="ML/data agents + evidence, observability, epistemic"
|
||||||
|
STR_STACK_SYSTEMS="Rust/Go agents + Rust-first + evidence, observability"
|
||||||
|
STR_STACK_MOBILE="Swift/Flutter agents + evidence, observability"
|
||||||
|
STR_PACK_INTRO="Optional discipline packs (safety is always on):"
|
||||||
|
STR_PACK_EVIDENCE="force evidence markers on numeric/cost claims"
|
||||||
|
STR_PACK_OBS="task timing, session dumps, agent telemetry"
|
||||||
|
STR_PACK_EPI="no-downgrade + alignment + recurrence reminders"
|
||||||
|
STR_PACK_ORCH="multi-agent fork logging + orchestrator git checks"
|
||||||
|
STR_PACK_GIT="block git push to github (for private-remote teams)"
|
||||||
|
STR_PACK_ENABLE="enable? [y/N]: "
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ STR_TR_AZURE_OPENAI="Azure OpenAI (deployment+ключ)"
|
||||||
STR_TR_GOOGLE_VERTEX="Google Vertex AI (GCP)"
|
STR_TR_GOOGLE_VERTEX="Google Vertex AI (GCP)"
|
||||||
STR_TR_LOCAL="Локально (Ollama/MLX/LMStudio)"
|
STR_TR_LOCAL="Локально (Ollama/MLX/LMStudio)"
|
||||||
STR_TR_PROXY="Прокси (LiteLLM/OpenRouter)"
|
STR_TR_PROXY="Прокси (LiteLLM/OpenRouter)"
|
||||||
STR_TR_SUBSCRIPTION="OAuth-подписка (ChatGPT)"
|
STR_TR_SUBSCRIPTION="Вход по подписке (Claude Code / ChatGPT — без API-ключа)"
|
||||||
|
|
||||||
# Сбор ключей
|
# Сбор ключей
|
||||||
STR_AUTH_INTRO="Аутентификация для"
|
STR_AUTH_INTRO="Аутентификация для"
|
||||||
|
|
@ -40,3 +40,9 @@ STR_MENU_CONFIRM="Подтвердить выбор?"
|
||||||
# Preflight-предупреждения
|
# Preflight-предупреждения
|
||||||
STR_PREFLIGHT_FAILED="Preflight упал — провайдер может не работать."
|
STR_PREFLIGHT_FAILED="Preflight упал — провайдер может не работать."
|
||||||
STR_PREFLIGHT_CONTINUE="Продолжить всё равно? [y/N]"
|
STR_PREFLIGHT_CONTINUE="Продолжить всё равно? [y/N]"
|
||||||
|
|
||||||
|
# Пояснения мастера + валидация ввода
|
||||||
|
STR_PICK_INVALID="введите один из показанных номеров"
|
||||||
|
STR_EXPLAIN_TRANSPORT="Как агенты обращаются к ИИ. subscription = вход по подписке, без API-ключа (Claude Code — вариант 1); direct-api = свой API-ключ. Нажми Enter для варианта по умолчанию."
|
||||||
|
STR_EXPLAIN_PROVIDER="Какой ИИ-сервис. Вариант 1 — рекомендуемый по умолчанию, нажми Enter."
|
||||||
|
STR_EXPLAIN_MODEL="Модель, которую используют агенты. Вариант 1 — рекомендуемый по умолчанию, нажми Enter."
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,18 @@
|
||||||
# when present.
|
# when present.
|
||||||
install_manifests() {
|
install_manifests() {
|
||||||
say "copying generic manifests -> $AGENTS_DIR/_manifests/ (skip if exists)"
|
say "copying generic manifests -> $AGENTS_DIR/_manifests/ (skip if exists)"
|
||||||
local copied=0 skipped=0 f name t has_templates=0
|
# Stack filter: when a stack profile is chosen, install only its agent set.
|
||||||
|
# Empty allowlist (no stack / non-interactive) => install ALL (back-compat).
|
||||||
|
local allow=""
|
||||||
|
if command -v resolve_selected_agent_manifests >/dev/null 2>&1; then
|
||||||
|
allow="$(resolve_selected_agent_manifests)"
|
||||||
|
fi
|
||||||
|
local copied=0 skipped=0 filtered=0 f name t has_templates=0
|
||||||
for f in "$KIT_DIR/_manifests/"*.toml; do
|
for f in "$KIT_DIR/_manifests/"*.toml; do
|
||||||
name="$(basename "$f")"
|
name="$(basename "$f")"
|
||||||
|
if [ -n "$allow" ] && ! printf '%s\n' "$allow" | grep -qx "${name%.toml}"; then
|
||||||
|
filtered=$((filtered+1)); continue
|
||||||
|
fi
|
||||||
if [[ -f "$AGENTS_DIR/_manifests/$name" ]]; then
|
if [[ -f "$AGENTS_DIR/_manifests/$name" ]]; then
|
||||||
skipped=$((skipped+1))
|
skipped=$((skipped+1))
|
||||||
else
|
else
|
||||||
|
|
@ -24,7 +33,11 @@ install_manifests() {
|
||||||
copied=$((copied+1))
|
copied=$((copied+1))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
say " copied $copied, skipped $skipped (already present)"
|
if [ -n "$allow" ]; then
|
||||||
|
say " copied $copied, skipped $skipped, stack-filtered $filtered"
|
||||||
|
else
|
||||||
|
say " copied $copied, skipped $skipped (already present)"
|
||||||
|
fi
|
||||||
|
|
||||||
for t in "$KIT_DIR/_templates/"*.template; do
|
for t in "$KIT_DIR/_templates/"*.template; do
|
||||||
[ -f "$t" ] && { has_templates=1; break; }
|
[ -f "$t" ] && { has_templates=1; break; }
|
||||||
|
|
@ -98,7 +111,15 @@ build_assembler() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run the built assembler in --in-place mode to write the agent .md files.
|
# Run the built assembler in --in-place mode to write the agent .md files.
|
||||||
|
# Tolerant by design: a handful of stale/broken manifests (e.g. a handoff to
|
||||||
|
# an agent not shipped in this profile, or an un-substituted template field)
|
||||||
|
# must NOT abort the whole install — hooks, skills, settings still need to
|
||||||
|
# land. The assembler prints per-manifest FAIL lines for visibility, and the
|
||||||
|
# commit-time assembler-validate gate stays strict, so genuine breakage is
|
||||||
|
# still caught at authoring time.
|
||||||
generate_agents() {
|
generate_agents() {
|
||||||
say "generating agent .md files (--in-place)"
|
say "generating agent .md files (--in-place)"
|
||||||
AGENT_ROOT="$AGENTS_DIR" "$AGENTS_DIR/_assembler/target/release/assemble" --in-place
|
if ! AGENT_ROOT="$AGENTS_DIR" "$AGENTS_DIR/_assembler/target/release/assemble" --in-place; then
|
||||||
|
warn "some agent manifests failed to assemble (see FAIL lines above) — continuing install"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ Usage: ./install.sh [flags]
|
||||||
|
|
||||||
NOTE: this classic installer is for power users (Rust primitives, custom
|
NOTE: this classic installer is for power users (Rust primitives, custom
|
||||||
profiles, full control). Most users should prefer the Claude Code plugin:
|
profiles, full control). Most users should prefer the Claude Code plugin:
|
||||||
/plugin marketplace add KeiSei84/KeiSeiKit
|
/plugin marketplace add KeiSeiLab/KeiSeiKit-1.0
|
||||||
/plugin install keisei@keisei-marketplace
|
/plugin install keisei@keisei-marketplace
|
||||||
See README.md "Plugin install (v0.16+, recommended)" and PLUGIN.md for
|
See README.md "Plugin install (v0.16+, recommended)" and PLUGIN.md for
|
||||||
details. The classic installer and the plugin can coexist — use whichever
|
details. The classic installer and the plugin can coexist — use whichever
|
||||||
|
|
|
||||||
|
|
@ -80,14 +80,29 @@ _mint_runner_token() {
|
||||||
printf '%s' "$token"
|
printf '%s' "$token"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Internal: register act_runner with the local Forgejo. Writes ${DATA}/.runner.
|
# v0.45 fix: brew installs `gitea-runner` (not `act_runner`); the binary is
|
||||||
# Args: <data_dir> <token>.
|
# 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.
|
||||||
_register_act_runner() {
|
_register_act_runner() {
|
||||||
local data_dir="$1"
|
local data_dir="$1"
|
||||||
local token="$2"
|
local token="$2"
|
||||||
local label="self-hosted,macos-arm64,native"
|
local label="self-hosted,macos-arm64,native"
|
||||||
local name="$(hostname -s)-keisei"
|
local name="$(hostname -s)-keisei"
|
||||||
( cd "$data_dir" && act_runner register \
|
local runner
|
||||||
|
runner="$(_runner_bin)" || { err "no runner binary found (looked for act_runner + gitea-runner)"; return 1; }
|
||||||
|
( cd "$data_dir" && "$runner" register \
|
||||||
--no-interactive \
|
--no-interactive \
|
||||||
--instance http://127.0.0.1:3001 \
|
--instance http://127.0.0.1:3001 \
|
||||||
--token "$token" \
|
--token "$token" \
|
||||||
|
|
@ -97,12 +112,19 @@ _register_act_runner() {
|
||||||
|
|
||||||
# Public entry: install + register + bootstrap the runner.
|
# Public entry: install + register + bootstrap the runner.
|
||||||
install_dev_hub_forgejo_runner() {
|
install_dev_hub_forgejo_runner() {
|
||||||
say "installing dev-hub-forgejo-runner (act_runner)"
|
say "installing dev-hub-forgejo-runner (Forgejo Actions runner)"
|
||||||
_require_forgejo_binary || return 1
|
_require_forgejo_binary || return 1
|
||||||
_require_forgejo_running || return 1
|
_require_forgejo_running || return 1
|
||||||
|
|
||||||
say "brew install act_runner"
|
# Prefer the Forgejo-official runner; fall back to the gitea-runner fork
|
||||||
brew install act_runner
|
# (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
|
||||||
|
|
||||||
local data_dir
|
local data_dir
|
||||||
data_dir="$(_runner_data_dir)"
|
data_dir="$(_runner_data_dir)"
|
||||||
|
|
@ -125,7 +147,9 @@ install_dev_hub_forgejo_runner() {
|
||||||
. "$KIT_DIR/install/lib-launchd.sh"
|
. "$KIT_DIR/install/lib-launchd.sh"
|
||||||
install_service forgejo-runner
|
install_service forgejo-runner
|
||||||
|
|
||||||
say "act_runner registered + running. Polling http://127.0.0.1:3001 for jobs."
|
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."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Public entry: stop + unload the runner. Keeps ${DATA}/.runner so re-install
|
# Public entry: stop + unload the runner. Keeps ${DATA}/.runner so re-install
|
||||||
|
|
|
||||||
|
|
@ -97,11 +97,19 @@ _dhf_bootstrap_admin_user() {
|
||||||
local kc_token_svc kc_pass_svc
|
local kc_token_svc kc_pass_svc
|
||||||
config="$(_dhf_app_ini)"
|
config="$(_dhf_app_ini)"
|
||||||
username="${KEI_FORGEJO_ADMIN_USER:-${USER:-denis}}"
|
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_token_svc="${KEI_FORGEJO_KC_TOKEN_SERVICE:-forgejo-api-token}"
|
||||||
kc_pass_svc="${KEI_FORGEJO_KC_PASS_SERVICE:-forgejo-admin-password}"
|
kc_pass_svc="${KEI_FORGEJO_KC_PASS_SERVICE:-forgejo-admin-password}"
|
||||||
# Detection: any rows beyond header in `admin user list`?
|
|
||||||
|
# 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.
|
||||||
user_count="$(forgejo --config "$config" admin user list 2>/dev/null \
|
user_count="$(forgejo --config "$config" admin user list 2>/dev/null \
|
||||||
| tail -n +2 | grep -cv '^$' || echo 0)"
|
| tail -n +2 | grep -cv '^$' || echo 0)"
|
||||||
if [ "$user_count" -gt 0 ]; then
|
if [ "$user_count" -gt 0 ]; then
|
||||||
|
|
@ -160,6 +168,8 @@ _dhf_bootstrap_admin_user() {
|
||||||
# Public — install entry point. Called from install.sh primitives phase.
|
# Public — install entry point. Called from install.sh primitives phase.
|
||||||
install_dev_hub_forgejo() {
|
install_dev_hub_forgejo() {
|
||||||
say "[dev-hub-forgejo] install starting"
|
say "[dev-hub-forgejo] install starting"
|
||||||
|
# shellcheck source=./lib-launchd.sh
|
||||||
|
. "$KIT_DIR/install/lib-launchd.sh" # install_service / detect_brew_prefix (was unsourced → command not found)
|
||||||
_dhf_check_brew || return 1
|
_dhf_check_brew || return 1
|
||||||
_dhf_brew_install || return 1
|
_dhf_brew_install || return 1
|
||||||
_dhf_ensure_data_dir || return 1
|
_dhf_ensure_data_dir || return 1
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,38 @@ _dhz_check_go_runtime() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step b — brew install zoekt (idempotent).
|
# 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.
|
||||||
_dhz_brew_install() {
|
_dhz_brew_install() {
|
||||||
say "installing zoekt via brew (idempotent)"
|
say "installing zoekt (idempotent)"
|
||||||
if ! brew install zoekt; then
|
if command -v zoekt-webserver >/dev/null 2>&1 && command -v zoekt-index >/dev/null 2>&1; then
|
||||||
err "brew install zoekt failed — see brew log above"
|
say " → zoekt already installed; skipping"
|
||||||
return 1
|
return 0
|
||||||
fi
|
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).
|
# Step c — ensure data dir tree (+ index dir).
|
||||||
|
|
@ -128,6 +153,8 @@ _dhz_print_banner() {
|
||||||
# Public — install entry point. Called from install.sh primitives phase.
|
# Public — install entry point. Called from install.sh primitives phase.
|
||||||
install_dev_hub_zoekt() {
|
install_dev_hub_zoekt() {
|
||||||
say "[dev-hub-zoekt] install starting"
|
say "[dev-hub-zoekt] install starting"
|
||||||
|
# shellcheck source=./lib-launchd.sh
|
||||||
|
. "$KIT_DIR/install/lib-launchd.sh" # install_service / detect_brew_prefix (was unsourced → command not found)
|
||||||
_dhz_check_brew || return 1
|
_dhz_check_brew || return 1
|
||||||
_dhz_check_go_runtime
|
_dhz_check_go_runtime
|
||||||
_dhz_brew_install || return 1
|
_dhz_brew_install || return 1
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,16 @@ install_hooks() {
|
||||||
say " installed $hook_count hook(s)"
|
say " installed $hook_count hook(s)"
|
||||||
|
|
||||||
# v0.17 — shared hook library (gate.sh + test-gate.sh)
|
# 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
|
if [ -d "$KIT_DIR/hooks/_lib" ]; then
|
||||||
mkdir -p "$HOOKS_DIR/_lib"
|
mkdir -p "$HOOKS_DIR/_lib"
|
||||||
local lib_count=0 lib_src lib_name
|
local lib_count=0 lib_src lib_name
|
||||||
for lib_src in "$KIT_DIR/hooks/_lib/"*.sh; do
|
for lib_src in "$KIT_DIR/hooks/_lib/"*.sh "$KIT_DIR/hooks/_lib/"*.toml; do
|
||||||
[ -f "$lib_src" ] || continue
|
[ -f "$lib_src" ] || continue
|
||||||
lib_name="$(basename "$lib_src")"
|
lib_name="$(basename "$lib_src")"
|
||||||
cp -f "$lib_src" "$HOOKS_DIR/_lib/$lib_name"
|
cp -f "$lib_src" "$HOOKS_DIR/_lib/$lib_name"
|
||||||
chmod +x "$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
|
||||||
lib_count=$((lib_count+1))
|
lib_count=$((lib_count+1))
|
||||||
done
|
done
|
||||||
say " installed $lib_count hook library file(s) -> $HOOKS_DIR/_lib/"
|
say " installed $lib_count hook library file(s) -> $HOOKS_DIR/_lib/"
|
||||||
|
|
@ -60,6 +62,10 @@ _jq_merge_hooks() {
|
||||||
| reduce ($add.hooks | keys[]) as $phase ($orig;
|
| reduce ($add.hooks | keys[]) as $phase ($orig;
|
||||||
.hooks[$phase] = (
|
.hooks[$phase] = (
|
||||||
((.hooks[$phase] // []) + ($add.hooks[$phase] // []))
|
((.hooks[$phase] // []) + ($add.hooks[$phase] // []))
|
||||||
|
# Normalize null/absent matcher to "" (Claude Code /doctor rejects null;
|
||||||
|
# pre-kit user hooks often have no matcher field) before group_by so
|
||||||
|
# null and "" collapse into one group.
|
||||||
|
| map(.matcher //= "")
|
||||||
| group_by(.matcher)
|
| group_by(.matcher)
|
||||||
| map(
|
| map(
|
||||||
.[0].matcher as $m
|
.[0].matcher as $m
|
||||||
|
|
@ -80,6 +86,13 @@ _jq_merge_hooks() {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# statusLine (KeiSei tamagotchi): set ONLY when the target has none.
|
||||||
|
# Never clobber an existing statusLine. Fresh-install path drops the
|
||||||
|
# snippet verbatim, so this only matters when merging into a
|
||||||
|
# pre-existing settings.json.
|
||||||
|
| if (.statusLine // null) == null and ($add.statusLine // null) != null
|
||||||
|
then .statusLine = $add.statusLine
|
||||||
|
else . end
|
||||||
' "$target" > "$tmp"
|
' "$target" > "$tmp"
|
||||||
if [ -s "$tmp" ] && jq -e . "$tmp" >/dev/null 2>&1; then
|
if [ -s "$tmp" ] && jq -e . "$tmp" >/dev/null 2>&1; then
|
||||||
mv "$tmp" "$target"
|
mv "$tmp" "$target"
|
||||||
|
|
@ -91,20 +104,64 @@ _jq_merge_hooks() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Write a filtered copy of the snippet keeping only hook entries whose command
|
||||||
|
# basename is in the newline allowlist (plus the cosmetic pet hooks, always
|
||||||
|
# kept). Drops emptied matcher groups. Echoes the temp path. Arg: $1 = allowlist.
|
||||||
|
filter_snippet_by_packs() {
|
||||||
|
local allow="$1" snippet="$KIT_DIR/settings-snippet.json" tmp
|
||||||
|
tmp="$(mktemp -t kei-snippet.XXXXXX)"
|
||||||
|
jq --arg allow "$allow" '
|
||||||
|
def b: sub("^.*/"; "") | sub("\\.sh$"; "");
|
||||||
|
def keep($ok; $c): (($c | b) as $x | ($ok | index($x)) != null)
|
||||||
|
or ($c | test("keisei-pet")) or ($c | test("^CMD="));
|
||||||
|
($allow | split("\n") | map(select(length > 0))) as $ok
|
||||||
|
| .hooks |= with_entries(
|
||||||
|
.value |= ( map(.hooks |= map(select(keep($ok; .command))))
|
||||||
|
| map(select((.hooks | length) > 0)) )
|
||||||
|
)
|
||||||
|
' "$snippet" > "$tmp" || { err "snippet filter failed"; rm -f "$tmp"; return 1; }
|
||||||
|
printf '%s' "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove every kit-owned hook entry from an existing settings.json (ownership =
|
||||||
|
# basename in the full pack universe, plus pet hooks). Foreign hooks survive.
|
||||||
|
# Lets reconfigure REMOVE deselected hooks (the merge alone is additive-only).
|
||||||
|
# Args: $1 = target settings.json, $2 = newline list of all kit hook basenames.
|
||||||
|
prune_kit_hooks() {
|
||||||
|
local target="$1" universe="$2" tmp
|
||||||
|
tmp="$(mktemp "$target.XXXXXX")"
|
||||||
|
jq --arg universe "$universe" '
|
||||||
|
def b: sub("^.*/"; "") | sub("\\.sh$"; "");
|
||||||
|
def owned($kit; $c): (($c | b) as $x | ($kit | index($x)) != null)
|
||||||
|
or ($c | test("keisei-pet")) or ($c | test("^CMD="));
|
||||||
|
($universe | split("\n") | map(select(length > 0))) as $kit
|
||||||
|
| .hooks |= with_entries(
|
||||||
|
.value |= ( map(.hooks |= map(select(owned($kit; .command) | not)))
|
||||||
|
| map(select((.hooks | length) > 0)) )
|
||||||
|
)
|
||||||
|
' "$target" > "$tmp" && mv "$tmp" "$target" || { err "prune failed"; rm -f "$tmp"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
activate_hooks() {
|
activate_hooks() {
|
||||||
local snippet="$KIT_DIR/settings-snippet.json"
|
local snippet="$KIT_DIR/settings-snippet.json"
|
||||||
local target="$HOME_DIR/.claude/settings.json"
|
local target="$HOME_DIR/.claude/settings.json"
|
||||||
[ -f "$snippet" ] || { warn "no snippet at $snippet"; return 0; }
|
[ -f "$snippet" ] || { warn "no snippet at $snippet"; return 0; }
|
||||||
|
local allow filtered
|
||||||
|
allow="$(resolve_selected_hook_basenames)"
|
||||||
|
filtered="$(filter_snippet_by_packs "$allow")" || return 1
|
||||||
if [ ! -f "$target" ]; then
|
if [ ! -f "$target" ]; then
|
||||||
local tmp
|
local tmp
|
||||||
tmp="$(mktemp "$target.XXXXXX")"
|
tmp="$(mktemp "$target.XXXXXX")"
|
||||||
jq 'del(._comment)' "$snippet" > "$tmp"
|
jq 'del(._comment)' "$filtered" > "$tmp"
|
||||||
mv "$tmp" "$target"
|
mv "$tmp" "$target"
|
||||||
say "created $target from snippet (no prior settings.json)"
|
rm -f "$filtered"
|
||||||
|
say "created $target from filtered snippet"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
backup_file "$target"
|
backup_file "$target"
|
||||||
_jq_merge_hooks "$snippet" "$target"
|
prune_kit_hooks "$target" "$(all_pack_basenames)"
|
||||||
|
_jq_merge_hooks "$filtered" "$target"
|
||||||
|
rm -f "$filtered"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Flag-or-prompt dispatcher, mirroring the v0.15 behavior:
|
# Flag-or-prompt dispatcher, mirroring the v0.15 behavior:
|
||||||
|
|
@ -122,7 +179,7 @@ maybe_activate_hooks() {
|
||||||
elif [ ! -f "$settings_file" ]; then
|
elif [ ! -f "$settings_file" ]; then
|
||||||
say "no existing settings.json; installing snippet"
|
say "no existing settings.json; installing snippet"
|
||||||
activate_hooks && DID_ACTIVATE=1
|
activate_hooks && DID_ACTIVATE=1
|
||||||
elif [ -t 0 ] && [ -t 1 ]; then
|
elif [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash
|
||||||
if [ "$COLOR" = "1" ]; then
|
if [ "$COLOR" = "1" ]; then
|
||||||
printf '\033[1;36m[install]\033[0m activate hooks now? [y/N] '
|
printf '\033[1;36m[install]\033[0m activate hooks now? [y/N] '
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,43 @@
|
||||||
# shellcheck shell=bash
|
# shellcheck shell=bash
|
||||||
# lib-log.sh — say / warn / err with optional ANSI color.
|
# lib-log.sh — say / warn / err with optional ANSI color + KeiSei banner.
|
||||||
# Honors NO_COLOR (no-color.org) and TTY detection on fd 1.
|
# Honors NO_COLOR (no-color.org). Color is ON when stdout is a TTY OR a
|
||||||
|
# controlling terminal is reachable via /dev/tty — the latter matters under
|
||||||
|
# `curl|bash`, where web-install.sh tees stdout (so `-t 1` is false even in an
|
||||||
|
# interactive session, but the terminal is still there via /dev/tty).
|
||||||
|
# This `-t 1`-with-/dev/tty test is COLOR detection, NOT an interactivity gate
|
||||||
|
# (see ~/.claude/rules/tty-interactivity-gate.md) — it pairs with no `-t 0`.
|
||||||
# Sourced by install.sh; no top-level execution.
|
# Sourced by install.sh; no top-level execution.
|
||||||
|
|
||||||
# ANSI on iff stdout is a TTY and NO_COLOR is unset.
|
if { [ -t 1 ] || { : < /dev/tty; } 2>/dev/null; } && [ "${NO_COLOR:-}" = "" ]; then
|
||||||
if [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]; then
|
|
||||||
COLOR=1
|
COLOR=1
|
||||||
else
|
else
|
||||||
COLOR=0
|
COLOR=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Brand palette: тёмно-жёлтый (gold) for [install], голубой (sky-blue) for KeiSei.
|
||||||
if [ "$COLOR" = "1" ]; then
|
if [ "$COLOR" = "1" ]; then
|
||||||
say() { printf '\033[1;36m[install]\033[0m %s\n' "$*"; }
|
KEI_GOLD=$'\033[38;5;178m' # тёмно-жёлтый — install prefix
|
||||||
|
KEI_BLUE=$'\033[38;5;39m' # голубой — logo / primitives
|
||||||
|
KEI_DIM=$'\033[2m'
|
||||||
|
KEI_RST=$'\033[0m'
|
||||||
|
say() { printf '%s[install]%s %s\n' "$KEI_GOLD" "$KEI_RST" "$*"; }
|
||||||
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; }
|
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; }
|
||||||
err() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; }
|
err() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; }
|
||||||
else
|
else
|
||||||
|
KEI_GOLD= KEI_BLUE= KEI_DIM= KEI_RST=
|
||||||
say() { printf '[install] %s\n' "$*"; }
|
say() { printf '[install] %s\n' "$*"; }
|
||||||
warn() { printf '[warn] %s\n' "$*"; }
|
warn() { printf '[warn] %s\n' "$*"; }
|
||||||
err() { printf '[error] %s\n' "$*" >&2; }
|
err() { printf '[error] %s\n' "$*" >&2; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# KeiSei ASCII banner — голубой logo, shown once at install start.
|
||||||
|
kei_banner() {
|
||||||
|
printf '\n'
|
||||||
|
printf '%s ██╗ ██╗███████╗██╗███████╗███████╗██╗%s\n' "$KEI_BLUE" "$KEI_RST"
|
||||||
|
printf '%s ██║ ██╔╝██╔════╝██║██╔════╝██╔════╝██║%s\n' "$KEI_BLUE" "$KEI_RST"
|
||||||
|
printf '%s █████╔╝ █████╗ ██║███████╗█████╗ ██║%s\n' "$KEI_BLUE" "$KEI_RST"
|
||||||
|
printf '%s ██╔═██╗ ██╔══╝ ██║╚════██║██╔══╝ ██║%s\n' "$KEI_BLUE" "$KEI_RST"
|
||||||
|
printf '%s ██║ ██╗███████╗██║███████║███████╗██║%s\n' "$KEI_BLUE" "$KEI_RST"
|
||||||
|
printf '%s ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝%s\n' "$KEI_BLUE" "$KEI_RST"
|
||||||
|
printf '%s KeiSeiKit · installing%s\n\n' "$KEI_GOLD" "$KEI_RST"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@ menu_should_skip() {
|
||||||
[ -n "$ADD_LIST" ] && return 0
|
[ -n "$ADD_LIST" ] && return 0
|
||||||
[ -n "$REMOVE_NAME" ] && return 0
|
[ -n "$REMOVE_NAME" ] && return 0
|
||||||
[ "$LIST_MODE" = "1" ] && return 0
|
[ "$LIST_MODE" = "1" ] && return 0
|
||||||
[ ! -t 0 ] && return 0
|
[ ! -t 0 ] && return 0 # interactive stdin only; not -t 1 (curl|bash tees stdout)
|
||||||
[ ! -t 1 ] && return 0
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,7 +37,7 @@ menu_whiptail_profile() {
|
||||||
"ops" "+ 9 infra tools — provision, ssh-check, firewall-diff" OFF \
|
"ops" "+ 9 infra tools — provision, ssh-check, firewall-diff" OFF \
|
||||||
"dev" "+ 17 dev tools — kei-migrate, kei-memory, deep-sleep" OFF \
|
"dev" "+ 17 dev tools — kei-migrate, kei-memory, deep-sleep" OFF \
|
||||||
"mcp" "+ 10 MCP tools — kei-router, kei-sage, kei-auth, kei-pet" OFF \
|
"mcp" "+ 10 MCP tools — kei-router, kei-sage, kei-auth, kei-pet" OFF \
|
||||||
"cortex" "+ 11 cortex stack — kei-cortex daemon + cortex-ui" OFF \
|
"cortex" "+ 11 cortex stack — kei-cortex daemon + UI primitives" OFF \
|
||||||
"full" "+ all 62 primitives (~5 min, 380 MB)" OFF \
|
"full" "+ all 62 primitives (~5 min, 380 MB)" OFF \
|
||||||
"local-mirror" "dev hub: cortex + Forgejo + CI runner (+ 13 prims)" OFF \
|
"local-mirror" "dev hub: cortex + Forgejo + CI runner (+ 13 prims)" OFF \
|
||||||
"dashboard" "local-mirror + projects-index + Datasette (+ 16 prims)" OFF \
|
"dashboard" "local-mirror + projects-index + Datasette (+ 16 prims)" OFF \
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,20 @@ onboarding_fallback_providers() {
|
||||||
printf "lmstudio-local\tlocal\tLM Studio (local)\t_\n"
|
printf "lmstudio-local\tlocal\tLM Studio (local)\t_\n"
|
||||||
printf "litellm-proxy\tproxy\tLiteLLM proxy (keisei.app)\tKEI_LITELLM_KEY\n"
|
printf "litellm-proxy\tproxy\tLiteLLM proxy (keisei.app)\tKEI_LITELLM_KEY\n"
|
||||||
printf "openrouter\tproxy\tOpenRouter\tOPENROUTER_API_KEY\n"
|
printf "openrouter\tproxy\tOpenRouter\tOPENROUTER_API_KEY\n"
|
||||||
|
printf "claude-code\tsubscription\tClaude Code (subscription — your claude CLI, no API key)\t_\n"
|
||||||
printf "codex\tsubscription\tOpenAI Codex (ChatGPT OAuth)\t_\n"
|
printf "codex\tsubscription\tOpenAI Codex (ChatGPT OAuth)\t_\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Уникальные транспорты — для первого экрана выбора.
|
# Уникальные транспорты — для первого экрана выбора.
|
||||||
|
# Claude-Code-native kit → выводим subscription + direct-api ПЕРВЫМИ, чтобы
|
||||||
|
# рекомендованный путь (Claude Code, опция 1) был дефолтом. Остальные следом.
|
||||||
onboarding_list_transports() {
|
onboarding_list_transports() {
|
||||||
onboarding_list_providers | awk -F'\t' '{print $2}' | sort -u
|
local all; all="$(onboarding_list_providers | awk -F'\t' '{print $2}' | sort -u)"
|
||||||
|
local t
|
||||||
|
for t in subscription direct-api; do
|
||||||
|
printf '%s\n' "$all" | grep -qx "$t" && echo "$t"
|
||||||
|
done
|
||||||
|
printf '%s\n' "$all" | grep -vxE 'subscription|direct-api' || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# Провайдеры внутри транспорта.
|
# Провайдеры внутри транспорта.
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ language = "$ONBOARDING_LANG"
|
||||||
transport = "$ONBOARDING_TRANSPORT"
|
transport = "$ONBOARDING_TRANSPORT"
|
||||||
provider = "$ONBOARDING_PROVIDER"
|
provider = "$ONBOARDING_PROVIDER"
|
||||||
default_model = "$ONBOARDING_MODEL"
|
default_model = "$ONBOARDING_MODEL"
|
||||||
|
stack_profile = "$ONBOARDING_STACK"
|
||||||
|
enabled_packs = "$ONBOARDING_PACKS"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Override для kei-model-router (HIGH аудит-1).
|
# Override для kei-model-router (HIGH аудит-1).
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,70 @@
|
||||||
# - lib-i18n.sh: STR_* словарь + i18n_available_languages + i18n_load_lang
|
# - lib-i18n.sh: STR_* словарь + i18n_available_languages + i18n_load_lang
|
||||||
# - lib-onboarding-registry.sh: списки провайдеров/моделей
|
# - lib-onboarding-registry.sh: списки провайдеров/моделей
|
||||||
|
|
||||||
|
# Read a validated 1-based menu choice. Non-numeric or out-of-range input is
|
||||||
|
# rejected with a re-prompt instead of crashing: bash arithmetic $((ans-1))
|
||||||
|
# treats a non-numeric "ans" (e.g. the user typing "claude") as a variable name
|
||||||
|
# → "unbound variable" under `set -u`. $1=option count, $2=prompt.
|
||||||
|
# Echoes a number in [1,$1] on stdout; prompts/warnings go to stderr.
|
||||||
|
_onb_read_choice() {
|
||||||
|
local max="$1" prompt="$2" ans
|
||||||
|
while true; do
|
||||||
|
read -r -p "$prompt" ans
|
||||||
|
ans="${ans:-1}"
|
||||||
|
if [[ "$ans" =~ ^[0-9]+$ ]] && [ "$ans" -ge 1 ] && [ "$ans" -le "$max" ]; then
|
||||||
|
printf '%s' "$ans"; return 0
|
||||||
|
fi
|
||||||
|
printf ' ⚠ %s\n' "${STR_PICK_INVALID:-please enter a number from 1 to $max}" >&2
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 6 — pick a stack profile (selects which discipline hooks + agents
|
||||||
|
# install) then optionally toggle discipline packs the stack does not pull.
|
||||||
|
# Sets ONBOARDING_STACK + ONBOARDING_PACKS. Reuses _onb_read_choice + stack_packs
|
||||||
|
# (lib-packs.sh). Default = minimal (safety hooks + core agents only).
|
||||||
|
onboarding_pick_stack() {
|
||||||
|
echo "" >&2
|
||||||
|
printf '%s\n' "${STR_PICK_STACK:-Pick your stack profile (selects hooks + agents):}" >&2
|
||||||
|
local opts="minimal web ml systems mobile" i=1 o d ans
|
||||||
|
for o in $opts; do
|
||||||
|
case "$o" in
|
||||||
|
minimal) d="${STR_STACK_MINIMAL:-safety hooks + core agents only}" ;;
|
||||||
|
web) d="${STR_STACK_WEB:-TS/frontend agents + evidence, observability}" ;;
|
||||||
|
ml) d="${STR_STACK_ML:-ML/data agents + evidence, observability, epistemic}" ;;
|
||||||
|
systems) d="${STR_STACK_SYSTEMS:-Rust/Go agents + Rust-first + evidence, observability}" ;;
|
||||||
|
mobile) d="${STR_STACK_MOBILE:-Swift/Flutter agents + evidence, observability}" ;;
|
||||||
|
esac
|
||||||
|
printf ' %d) %-8s — %s\n' "$i" "$o" "$d" >&2
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
ans="$(_onb_read_choice 5 "${STR_PICK_STACK_PROMPT:-[1-5, default 1=minimal]: }")"
|
||||||
|
ONBOARDING_STACK="$(echo "$opts" | cut -d' ' -f"$ans")"
|
||||||
|
[ -n "$ONBOARDING_STACK" ] || ONBOARDING_STACK="minimal"
|
||||||
|
|
||||||
|
# Offer discipline packs the chosen stack does not already enable.
|
||||||
|
local stackpacks p pd reply
|
||||||
|
stackpacks=" $(command -v stack_packs >/dev/null 2>&1 && stack_packs "$ONBOARDING_STACK") "
|
||||||
|
ONBOARDING_PACKS=""
|
||||||
|
printf '%s\n' "${STR_PACK_INTRO:-Optional discipline packs (safety is always on):}" >&2
|
||||||
|
for p in evidence observability epistemic orchestration git-guard; do
|
||||||
|
case "$stackpacks" in *" $p "*) continue ;; esac
|
||||||
|
case "$p" in
|
||||||
|
evidence) pd="${STR_PACK_EVIDENCE:-force evidence markers on numeric/cost claims}" ;;
|
||||||
|
observability) pd="${STR_PACK_OBS:-task timing, session dumps, agent telemetry}" ;;
|
||||||
|
epistemic) pd="${STR_PACK_EPI:-no-downgrade + alignment + recurrence reminders}" ;;
|
||||||
|
orchestration) pd="${STR_PACK_ORCH:-multi-agent fork logging + orchestrator git checks}" ;;
|
||||||
|
git-guard) pd="${STR_PACK_GIT:-block git push to github (for private-remote teams)}" ;;
|
||||||
|
esac
|
||||||
|
printf ' + %-13s — %s\n' "$p" "$pd" >&2
|
||||||
|
read -r -p " ${STR_PACK_ENABLE:-enable? [y/N]: }" reply
|
||||||
|
case "$reply" in y|Y|yes|YES) ONBOARDING_PACKS="$ONBOARDING_PACKS $p" ;; esac
|
||||||
|
done
|
||||||
|
ONBOARDING_PACKS="$(echo "$ONBOARDING_PACKS" | sed 's/^ *//;s/ *$//')"
|
||||||
|
if command -v say >/dev/null 2>&1; then
|
||||||
|
say "stack: $ONBOARDING_STACK packs: ${ONBOARDING_PACKS:-(stack defaults only)}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
onboarding_pick_language() {
|
onboarding_pick_language() {
|
||||||
local langs
|
local langs
|
||||||
langs="$(i18n_available_languages 2>/dev/null)"
|
langs="$(i18n_available_languages 2>/dev/null)"
|
||||||
|
|
@ -40,11 +104,10 @@ onboarding_pick_language() {
|
||||||
while IFS=$'\t' read -r code name; do
|
while IFS=$'\t' read -r code name; do
|
||||||
[ -z "$code" ] && continue
|
[ -z "$code" ] && continue
|
||||||
codes+=("$code")
|
codes+=("$code")
|
||||||
printf " %2d) %s — %s\n" "$i" "$code" "$name" >&2
|
printf " %2d) ${KEI_BLUE:-}%s${KEI_RST:-} — ${KEI_GOLD:-}%s${KEI_RST:-}\n" "$i" "$code" "$name" >&2
|
||||||
i=$((i+1))
|
i=$((i+1))
|
||||||
done <<< "$langs"
|
done <<< "$langs"
|
||||||
read -r -p "[1-${#codes[@]}, default 1=en]: " ans
|
ans="$(_onb_read_choice "${#codes[@]}" "[1-${#codes[@]}, default 1=en]: ")"
|
||||||
ans="${ans:-1}"
|
|
||||||
ONBOARDING_LANG="${codes[$((ans-1))]:-en}"
|
ONBOARDING_LANG="${codes[$((ans-1))]:-en}"
|
||||||
fi
|
fi
|
||||||
command -v i18n_load_lang >/dev/null 2>&1 && i18n_load_lang "$ONBOARDING_LANG"
|
command -v i18n_load_lang >/dev/null 2>&1 && i18n_load_lang "$ONBOARDING_LANG"
|
||||||
|
|
@ -76,6 +139,7 @@ onboarding_pick_transport() {
|
||||||
else
|
else
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "$prompt" >&2
|
echo "$prompt" >&2
|
||||||
|
echo " ${STR_EXPLAIN_TRANSPORT:-How the agents reach the AI. subscription = log in with your plan (no API key); direct-api = your own API key. Default is fine for most.}" >&2
|
||||||
local i=1
|
local i=1
|
||||||
declare -a opts=()
|
declare -a opts=()
|
||||||
while IFS= read -r tr; do
|
while IFS= read -r tr; do
|
||||||
|
|
@ -83,8 +147,7 @@ onboarding_pick_transport() {
|
||||||
echo " $i) $tr" >&2
|
echo " $i) $tr" >&2
|
||||||
i=$((i+1))
|
i=$((i+1))
|
||||||
done <<< "$transports"
|
done <<< "$transports"
|
||||||
read -r -p "[1-${#opts[@]}, default 1]: " ans
|
ans="$(_onb_read_choice "${#opts[@]}" "[1-${#opts[@]}, default 1]: ")"
|
||||||
ans="${ans:-1}"
|
|
||||||
ONBOARDING_TRANSPORT="${opts[$((ans-1))]:-direct-api}"
|
ONBOARDING_TRANSPORT="${opts[$((ans-1))]:-direct-api}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +174,7 @@ onboarding_pick_provider() {
|
||||||
else
|
else
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "${STR_PICK_PROVIDER:-Provider within} $ONBOARDING_TRANSPORT:" >&2
|
echo "${STR_PICK_PROVIDER:-Provider within} $ONBOARDING_TRANSPORT:" >&2
|
||||||
|
echo " ${STR_EXPLAIN_PROVIDER:-Which AI service. Option 1 is the recommended default.}" >&2
|
||||||
declare -a ids=()
|
declare -a ids=()
|
||||||
local i=1
|
local i=1
|
||||||
while IFS=$'\t' read -r id dn ae; do
|
while IFS=$'\t' read -r id dn ae; do
|
||||||
|
|
@ -118,8 +182,7 @@ onboarding_pick_provider() {
|
||||||
echo " $i) $id — $dn" >&2
|
echo " $i) $id — $dn" >&2
|
||||||
i=$((i+1))
|
i=$((i+1))
|
||||||
done <<< "$rows"
|
done <<< "$rows"
|
||||||
read -r -p "[1-${#ids[@]}, default 1]: " ans
|
ans="$(_onb_read_choice "${#ids[@]}" "[1-${#ids[@]}, default 1]: ")"
|
||||||
ans="${ans:-1}"
|
|
||||||
ONBOARDING_PROVIDER="${ids[$((ans-1))]:-${ids[0]}}"
|
ONBOARDING_PROVIDER="${ids[$((ans-1))]:-${ids[0]}}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -135,6 +198,12 @@ onboarding_pick_model() {
|
||||||
local rows; rows=$(onboarding_models_for_provider "$lookup")
|
local rows; rows=$(onboarding_models_for_provider "$lookup")
|
||||||
[ -z "$rows" ] && rows=$(printf "claude-sonnet-4-6\tClaude Sonnet 4.6 (fallback)\n")
|
[ -z "$rows" ] && rows=$(printf "claude-sonnet-4-6\tClaude Sonnet 4.6 (fallback)\n")
|
||||||
|
|
||||||
|
# Single model → auto-select, no dead-end prompt (mirrors provider count==1).
|
||||||
|
if [ "$(printf '%s\n' "$rows" | grep -c .)" = "1" ]; then
|
||||||
|
ONBOARDING_MODEL=$(printf '%s\n' "$rows" | head -1 | awk -F'\t' '{print $1}')
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v whiptail >/dev/null 2>&1; then
|
if command -v whiptail >/dev/null 2>&1; then
|
||||||
local args=()
|
local args=()
|
||||||
while IFS=$'\t' read -r id dn; do
|
while IFS=$'\t' read -r id dn; do
|
||||||
|
|
@ -146,6 +215,7 @@ onboarding_pick_model() {
|
||||||
else
|
else
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "${STR_PICK_MODEL:-Models for} $lookup:" >&2
|
echo "${STR_PICK_MODEL:-Models for} $lookup:" >&2
|
||||||
|
echo " ${STR_EXPLAIN_MODEL:-Default model the agents use. Option 1 is the recommended default.}" >&2
|
||||||
declare -a ids=()
|
declare -a ids=()
|
||||||
local i=1
|
local i=1
|
||||||
while IFS=$'\t' read -r id dn; do
|
while IFS=$'\t' read -r id dn; do
|
||||||
|
|
@ -153,8 +223,7 @@ onboarding_pick_model() {
|
||||||
echo " $i) $id — $dn" >&2
|
echo " $i) $id — $dn" >&2
|
||||||
i=$((i+1))
|
i=$((i+1))
|
||||||
done <<< "$rows"
|
done <<< "$rows"
|
||||||
read -r -p "[1-${#ids[@]}, default 1]: " ans
|
ans="$(_onb_read_choice "${#ids[@]}" "[1-${#ids[@]}, default 1]: ")"
|
||||||
ans="${ans:-1}"
|
|
||||||
ONBOARDING_MODEL="${ids[$((ans-1))]:-${ids[0]}}"
|
ONBOARDING_MODEL="${ids[$((ans-1))]:-${ids[0]}}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ ONBOARDING_LANG=""
|
||||||
ONBOARDING_TRANSPORT=""
|
ONBOARDING_TRANSPORT=""
|
||||||
ONBOARDING_PROVIDER=""
|
ONBOARDING_PROVIDER=""
|
||||||
ONBOARDING_MODEL=""
|
ONBOARDING_MODEL=""
|
||||||
|
ONBOARDING_STACK=""
|
||||||
|
ONBOARDING_PACKS=""
|
||||||
declare -a ONBOARDING_AUTH_ENV_KEYS=()
|
declare -a ONBOARDING_AUTH_ENV_KEYS=()
|
||||||
declare -a ONBOARDING_AUTH_ENV_VALUES=()
|
declare -a ONBOARDING_AUTH_ENV_VALUES=()
|
||||||
|
|
||||||
|
|
@ -41,32 +43,36 @@ REGISTRY_MODELS="$KIT_DIR/_blocks/registries/models.toml"
|
||||||
onboarding_should_run() {
|
onboarding_should_run() {
|
||||||
[ -f "$ONBOARDED_FLAG" ] && return 1
|
[ -f "$ONBOARDED_FLAG" ] && return 1
|
||||||
[ "${KEISEI_SKIP_ONBOARD:-}" = "1" ] && return 1
|
[ "${KEISEI_SKIP_ONBOARD:-}" = "1" ] && return 1
|
||||||
|
# Interactive iff stdin is a terminal. We deliberately do NOT require -t 1:
|
||||||
|
# the curl|bash bootstrapper (web-install.sh) tees stdout to a logfile, so
|
||||||
|
# -t 1 is false even in an interactive session. Prompts go to stderr, input
|
||||||
|
# reads from stdin — an interactive stdin is the only real requirement.
|
||||||
[ ! -t 0 ] && return 1
|
[ ! -t 0 ] && return 1
|
||||||
[ ! -t 1 ] && return 1
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Оркестратор: 5 шагов + preflight + запись.
|
# Оркестратор: 6 шагов + preflight + запись.
|
||||||
onboarding_run() {
|
onboarding_run() {
|
||||||
onboarding_should_run || return 0
|
onboarding_should_run || return 0
|
||||||
|
|
||||||
if command -v say >/dev/null 2>&1; then
|
if command -v say >/dev/null 2>&1; then
|
||||||
say "${STR_ONBOARDING_INTRO:-Onboarding wizard (5 steps)}"
|
say "${STR_ONBOARDING_INTRO:-Onboarding wizard (6 steps)}"
|
||||||
else
|
else
|
||||||
echo "── KeiSei: ${STR_ONBOARDING_INTRO:-onboarding (5 steps)} ──" >&2
|
echo "── KeiSei: ${STR_ONBOARDING_INTRO:-onboarding (6 steps)} ──" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
onboarding_pick_language
|
onboarding_pick_language
|
||||||
onboarding_pick_transport
|
onboarding_pick_transport
|
||||||
onboarding_pick_provider
|
onboarding_pick_provider
|
||||||
onboarding_pick_model
|
onboarding_pick_model
|
||||||
|
onboarding_pick_stack
|
||||||
|
|
||||||
# Preflight — провайдер-специфичная проверка CLI/daemon до сбора ключей.
|
# Preflight — провайдер-специфичная проверка CLI/daemon до сбора ключей.
|
||||||
if command -v preflight_run >/dev/null 2>&1; then
|
if command -v preflight_run >/dev/null 2>&1; then
|
||||||
if ! preflight_run "$ONBOARDING_PROVIDER"; then
|
if ! preflight_run "$ONBOARDING_PROVIDER"; then
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo " ⚠ ${STR_PREFLIGHT_FAILED:-Preflight failed — provider may not work.}" >&2
|
echo " ⚠ ${STR_PREFLIGHT_FAILED:-Preflight failed — provider may not work.}" >&2
|
||||||
if [ -t 0 ] && [ -t 1 ]; then
|
if [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash
|
||||||
read -r -p " ${STR_PREFLIGHT_CONTINUE:-Continue anyway? [y/N]} " _ans
|
read -r -p " ${STR_PREFLIGHT_CONTINUE:-Continue anyway? [y/N]} " _ans
|
||||||
case "$_ans" in
|
case "$_ans" in
|
||||||
y|Y|yes|да|Да)
|
y|Y|yes|да|Да)
|
||||||
|
|
@ -92,4 +98,9 @@ onboarding_run() {
|
||||||
say " ${STR_DONE_CONFIG:-config:} $ONBOARDING_CONFIG"
|
say " ${STR_DONE_CONFIG:-config:} $ONBOARDING_CONFIG"
|
||||||
[ "${#ONBOARDING_AUTH_ENV_KEYS[@]}" -gt 0 ] && say " ${STR_DONE_SECRETS:-secrets:} $SECRETS_ENV (chmod 600)"
|
[ "${#ONBOARDING_AUTH_ENV_KEYS[@]}" -gt 0 ] && say " ${STR_DONE_SECRETS:-secrets:} $SECRETS_ENV (chmod 600)"
|
||||||
fi
|
fi
|
||||||
|
# MUST end on success: the last statement above is a short-circuit `&&` that
|
||||||
|
# evaluates false when the provider has no auth keys (claude-code / codex /
|
||||||
|
# local) → function would return 1 → `set -e` aborts the whole install at the
|
||||||
|
# onboarding_run call. Subscription/local providers are exactly the no-key case.
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
install/lib-packs.sh
Normal file
74
install/lib-packs.sh
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# shellcheck shell=bash
|
||||||
|
# lib-packs.sh — hook-pack + stack-profile resolver. Reads _primitives/hook-packs.toml
|
||||||
|
# via the generic _toml_array reader (from lib-profile.sh). Decides which hooks get
|
||||||
|
# wired into settings.json and which agent manifests install, based on the user's
|
||||||
|
# onboarding selection (or the safe minimal default when none was made).
|
||||||
|
#
|
||||||
|
# Requires: _toml_array from lib-profile.sh.
|
||||||
|
# Reads globals: $KIT_DIR (kit checkout), optional $ONBOARDING_STACK / $ONBOARDING_PACKS
|
||||||
|
# (live onboarding), optional $ONBOARDING_CONFIG (persisted selection).
|
||||||
|
|
||||||
|
PACKS_TOML="${PACKS_TOML:-$KIT_DIR/_primitives/hook-packs.toml}"
|
||||||
|
|
||||||
|
# --- thin table readers ---------------------------------------------------
|
||||||
|
pack_hooks() { _toml_array "$PACKS_TOML" "pack" "$1"; }
|
||||||
|
stack_packs() { _toml_array "$PACKS_TOML" "stack-packs" "$1"; }
|
||||||
|
stack_agent_groups() { _toml_array "$PACKS_TOML" "stack-agents" "$1"; }
|
||||||
|
agent_set_members() { _toml_array "$PACKS_TOML" "agent-set" "$1"; }
|
||||||
|
_packs_always() { _toml_array "$PACKS_TOML" "pack-always" "base"; }
|
||||||
|
|
||||||
|
# --- selection (live onboarding globals > persisted toml > none) ----------
|
||||||
|
# Echo the chosen stack, or empty if the user never chose one.
|
||||||
|
_packs_chosen_stack() {
|
||||||
|
if [ -n "${ONBOARDING_STACK:-}" ]; then printf '%s' "$ONBOARDING_STACK"; return; fi
|
||||||
|
local cfg="${ONBOARDING_CONFIG:-$HOME/.claude/config/onboarding.toml}"
|
||||||
|
[ -f "$cfg" ] && grep -E '^stack_profile[[:space:]]*=' "$cfg" \
|
||||||
|
| sed -E 's/.*=[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/' | head -1
|
||||||
|
}
|
||||||
|
# Echo the explicitly enabled packs (space-separated), or empty.
|
||||||
|
_packs_chosen_packs() {
|
||||||
|
if [ -n "${ONBOARDING_PACKS:-}" ]; then printf '%s' "$ONBOARDING_PACKS"; return; fi
|
||||||
|
local cfg="${ONBOARDING_CONFIG:-$HOME/.claude/config/onboarding.toml}"
|
||||||
|
[ -f "$cfg" ] && grep -E '^enabled_packs[[:space:]]*=' "$cfg" \
|
||||||
|
| sed -E 's/.*=[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- resolution -----------------------------------------------------------
|
||||||
|
# Newline list of hook basenames to wire on install: safety + always + every
|
||||||
|
# pack pulled by the chosen stack + every explicitly enabled pack. Default
|
||||||
|
# (no choice) = safety + always only.
|
||||||
|
resolve_selected_hook_basenames() {
|
||||||
|
local stack packs p out
|
||||||
|
stack="$(_packs_chosen_stack)"; stack="${stack:-minimal}"
|
||||||
|
packs="$(_packs_chosen_packs)"
|
||||||
|
out="$(pack_hooks safety) $(_packs_always)"
|
||||||
|
for p in $(stack_packs "$stack") $packs; do
|
||||||
|
out="$out $(pack_hooks "$p")"
|
||||||
|
done
|
||||||
|
echo "$out" | tr ' ' '\n' | grep -v '^$' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
# Newline list of agent manifest basenames to install for the chosen stack
|
||||||
|
# (base group always included). EMPTY when no stack was chosen → caller
|
||||||
|
# installs ALL manifests (power-user / non-interactive default).
|
||||||
|
resolve_selected_agent_manifests() {
|
||||||
|
local stack g out
|
||||||
|
stack="$(_packs_chosen_stack)"
|
||||||
|
[ -n "$stack" ] || return 0
|
||||||
|
out=""
|
||||||
|
for g in $(stack_agent_groups "$stack"); do
|
||||||
|
out="$out $(agent_set_members "$g")"
|
||||||
|
done
|
||||||
|
echo "$out" | tr ' ' '\n' | grep -v '^$' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
# Newline list of EVERY kit-owned hook basename (all packs + always). Used by
|
||||||
|
# prune_kit_hooks to identify which settings.json entries the kit owns.
|
||||||
|
all_pack_basenames() {
|
||||||
|
local p out=""
|
||||||
|
for p in safety evidence observability epistemic orchestration git-guard stack-rust; do
|
||||||
|
out="$out $(pack_hooks "$p")"
|
||||||
|
done
|
||||||
|
out="$out $(_packs_always)"
|
||||||
|
echo "$out" | tr ' ' '\n' | grep -v '^$' | sort -u
|
||||||
|
}
|
||||||
|
|
@ -100,7 +100,7 @@ _print_primitive_rows() {
|
||||||
[ -z "$name" ] && continue
|
[ -z "$name" ] && continue
|
||||||
kind="$(primitive_field "$name" kind 2>/dev/null || echo '?')"
|
kind="$(primitive_field "$name" kind 2>/dev/null || echo '?')"
|
||||||
extra="$(primitive_time_secs "$name")s, $(( $(primitive_disk_kb "$name") / 1024 )) MB"
|
extra="$(primitive_time_secs "$name")s, $(( $(primitive_disk_kb "$name") / 1024 )) MB"
|
||||||
printf ' + %-22s (%s, ~%s)\n' "$name" "$kind" "$extra"
|
printf ' + %s%-22s%s (%s, ~%s)\n' "${KEI_BLUE:-}" "$name" "${KEI_RST:-}" "$kind" "$extra"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ preflight_offer_install() {
|
||||||
echo " ⚠ $cli не найден." >&2
|
echo " ⚠ $cli не найден." >&2
|
||||||
echo " Установить: $install_cmd" >&2
|
echo " Установить: $install_cmd" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
if [ -t 0 ] && [ -t 1 ]; then
|
if [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash
|
||||||
echo " ⓘ команда: $install_cmd" >&2
|
echo " ⓘ команда: $install_cmd" >&2
|
||||||
read -r -p " Поставить сейчас? [y/N/skip] " ans
|
read -r -p " Поставить сейчас? [y/N/skip] " ans
|
||||||
case "$ans" in
|
case "$ans" in
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,29 @@
|
||||||
# Reads globals: $PROFILE, $CUSTOM_PRIMS, $MANIFEST.
|
# Reads globals: $PROFILE, $CUSTOM_PRIMS, $MANIFEST.
|
||||||
# Sets global: $PROFILE_PRIMS (space-separated primitive names).
|
# Sets global: $PROFILE_PRIMS (space-separated primitive names).
|
||||||
|
|
||||||
# Hard checks: cargo + jq. Exit 1 on missing — without them the install
|
# Does the resolved profile contain at least one rust primitive? Only then
|
||||||
# (or the installed hooks afterwards) cannot function.
|
# is a functional cargo toolchain a HARD requirement. Profiles like minimal
|
||||||
|
# (0 primitives) and shell-only customs build nothing and must install
|
||||||
|
# without Rust — README promises "minimal — NO Rust compile". Requires
|
||||||
|
# PROFILE_PRIMS already resolved (resolve_profile_prims, called by install.sh
|
||||||
|
# and at the top of check_prereqs).
|
||||||
|
_profile_needs_cargo() {
|
||||||
|
local p kind
|
||||||
|
for p in ${PROFILE_PRIMS:-}; do
|
||||||
|
kind="$(primitive_field "$p" kind 2>/dev/null || true)"
|
||||||
|
[ "$kind" = "rust" ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hard checks: cargo + jq, both always required.
|
||||||
|
# cargo — the agent assembler (build_assembler) compiles a small rust binary
|
||||||
|
# to generate the agent .md files on EVERY profile, so cargo is
|
||||||
|
# non-negotiable. The heavy 105-crate substrate *workspace* build is
|
||||||
|
# a separate concern: install.sh auto-sets KEI_SKIP_RUST for profiles
|
||||||
|
# with no rust primitives so minimal stays fast (assembler only).
|
||||||
|
# jq — the installed hooks parse Claude Code JSON via jq and would abort
|
||||||
|
# tool calls without it.
|
||||||
check_hard_prereqs() {
|
check_hard_prereqs() {
|
||||||
say "checking prerequisites"
|
say "checking prerequisites"
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
|
|
@ -103,9 +124,12 @@ check_soft_prereqs() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Top-level orchestrator: hard first (exit on miss), then resolve + soft.
|
# Top-level orchestrator: resolve profile first (idempotent — install.sh
|
||||||
|
# already resolved it before the no-execute/skip-prereqs branches), so the
|
||||||
|
# conditional cargo gate in check_hard_prereqs can see PROFILE_PRIMS; then
|
||||||
|
# hard checks (exit on miss); then soft warnings.
|
||||||
check_prereqs() {
|
check_prereqs() {
|
||||||
check_hard_prereqs
|
|
||||||
resolve_profile_prims
|
resolve_profile_prims
|
||||||
|
check_hard_prereqs
|
||||||
check_soft_prereqs
|
check_soft_prereqs
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ copy_shell_primitive() {
|
||||||
mkdir -p "$AGENTS_DIR/_primitives"
|
mkdir -p "$AGENTS_DIR/_primitives"
|
||||||
cp -f "$src" "$dst"
|
cp -f "$src" "$dst"
|
||||||
chmod +x "$dst"
|
chmod +x "$dst"
|
||||||
say " + shell: $name ($file)"
|
say " + shell: ${KEI_BLUE:-}$name${KEI_RST:-} ($file)"
|
||||||
}
|
}
|
||||||
|
|
||||||
remove_shell_primitive() {
|
remove_shell_primitive() {
|
||||||
|
|
@ -65,7 +65,7 @@ copy_rust_primitive() {
|
||||||
cp -rf "$src/$sibling/"* "$dst/$sibling/" 2>/dev/null || true
|
cp -rf "$src/$sibling/"* "$dst/$sibling/" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
say " + rust: $name (crate $crate)"
|
say " + rust: ${KEI_BLUE:-}$name${KEI_RST:-} (crate $crate)"
|
||||||
}
|
}
|
||||||
|
|
||||||
remove_rust_primitive() {
|
remove_rust_primitive() {
|
||||||
|
|
@ -103,7 +103,7 @@ copy_node_primitive() {
|
||||||
find "$dst" -depth -name "$ex" -exec rm -rf {} + 2>/dev/null || true
|
find "$dst" -depth -name "$ex" -exec rm -rf {} + 2>/dev/null || true
|
||||||
done < <(_node_excludes)
|
done < <(_node_excludes)
|
||||||
fi
|
fi
|
||||||
say " + node: $name (path $rel)"
|
say " + node: ${KEI_BLUE:-}$name${KEI_RST:-} (path $rel)"
|
||||||
}
|
}
|
||||||
|
|
||||||
remove_node_primitive() {
|
remove_node_primitive() {
|
||||||
|
|
@ -171,7 +171,7 @@ install_external_primitive() {
|
||||||
err "external primitive $name: entry point $slug not found in $file"
|
err "external primitive $name: entry point $slug not found in $file"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
say " + external: $name (via $file)"
|
say " + external: ${KEI_BLUE:-}$name${KEI_RST:-} (via $file)"
|
||||||
"$slug" || warn "$name install failed — re-run after fixing prereqs"
|
"$slug" || warn "$name install failed — re-run after fixing prereqs"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,16 @@ have_python_toml() {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Echo space-separated primitive names for a given profile.
|
# Generic one-line-array TOML reader. Echoes space-separated values of
|
||||||
# Usage: profile_members <profile-name>
|
# [<table>]
|
||||||
profile_members() {
|
# <key> = ["a", "b", ...]
|
||||||
local profile="$1"
|
# python-tomllib preferred; awk fallback handles one-line arrays only.
|
||||||
[ -f "$MANIFEST" ] || { err "MANIFEST.toml not found at $MANIFEST"; return 1; }
|
# Usage: _toml_array <file> <table> <key>
|
||||||
|
_toml_array() {
|
||||||
|
local file="$1" table="$2" key="$3"
|
||||||
|
[ -f "$file" ] || return 1
|
||||||
if have_python_toml; then
|
if have_python_toml; then
|
||||||
python3 - "$MANIFEST" "$profile" <<'PY' 2>/dev/null || return 1
|
python3 - "$file" "$table" "$key" <<'PY' 2>/dev/null || return 1
|
||||||
import sys
|
import sys
|
||||||
try:
|
try:
|
||||||
import tomllib
|
import tomllib
|
||||||
|
|
@ -33,20 +36,19 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import toml as tomllib
|
import toml as tomllib
|
||||||
mode = "r"
|
mode = "r"
|
||||||
path, prof = sys.argv[1], sys.argv[2]
|
path, table, key = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
with open(path, mode) as f:
|
with open(path, mode) as f:
|
||||||
data = tomllib.load(f) if mode == "rb" else tomllib.load(f)
|
data = tomllib.load(f)
|
||||||
members = data.get("profile", {}).get(prof)
|
vals = data.get(table, {}).get(key)
|
||||||
if members is None:
|
if vals is None:
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
print(" ".join(members))
|
print(" ".join(vals))
|
||||||
PY
|
PY
|
||||||
else
|
else
|
||||||
# awk fallback — only handles `profile.<name> = [...]` on one line
|
awk -v table="$table" -v key="$key" '
|
||||||
awk -v prof="$profile" '
|
$0 ~ "^\\[" table "\\]" { in_t=1; next }
|
||||||
/^\[profile\]/ { in_profile=1; next }
|
/^\[/ { in_t=0 }
|
||||||
/^\[/ && !/^\[profile\]/ { in_profile=0 }
|
in_t && $0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
|
||||||
in_profile && $0 ~ "^[[:space:]]*" prof "[[:space:]]*=" {
|
|
||||||
line = $0
|
line = $0
|
||||||
sub(/^[^\[]*\[/, "", line)
|
sub(/^[^\[]*\[/, "", line)
|
||||||
sub(/\].*$/, "", line)
|
sub(/\].*$/, "", line)
|
||||||
|
|
@ -55,10 +57,18 @@ PY
|
||||||
print line
|
print line
|
||||||
exit
|
exit
|
||||||
}
|
}
|
||||||
' "$MANIFEST"
|
' "$file"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Echo space-separated primitive names for a given profile.
|
||||||
|
# Usage: profile_members <profile-name>
|
||||||
|
profile_members() {
|
||||||
|
local profile="$1"
|
||||||
|
[ -f "$MANIFEST" ] || { err "MANIFEST.toml not found at $MANIFEST"; return 1; }
|
||||||
|
_toml_array "$MANIFEST" "profile" "$profile"
|
||||||
|
}
|
||||||
|
|
||||||
# Echo a field of a primitive. Usage: primitive_field <name> <field>
|
# Echo a field of a primitive. Usage: primitive_field <name> <field>
|
||||||
# field ∈ { kind, file, crate, desc, deps }
|
# field ∈ { kind, file, crate, desc, deps }
|
||||||
primitive_field() {
|
primitive_field() {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
# Path A — download from latest github release (fast, no Rust required):
|
# Path A — download from latest github release (fast, no Rust required):
|
||||||
# 1. Detect platform via uname → Rust target triple.
|
# 1. Detect platform via uname → Rust target triple.
|
||||||
# 2. Fetch keisei-${TARGET}.tar.gz from
|
# 2. Fetch keisei-${TARGET}.tar.gz from
|
||||||
# https://github.com/KeiSei84/KeiSeiKit-1.0/releases/latest/download/
|
# https://github.com/KeiSeiLab/KeiSeiKit-1.0/releases/latest/download/
|
||||||
# 3. Verify sha256.
|
# 3. Verify sha256.
|
||||||
# 4. Extract into target/release/.
|
# 4. Extract into target/release/.
|
||||||
#
|
#
|
||||||
|
|
@ -62,7 +62,7 @@ download_release_tarball() {
|
||||||
local target="$1"
|
local target="$1"
|
||||||
[ -n "$target" ] || return 1
|
[ -n "$target" ] || return 1
|
||||||
local tarball="keisei-${target}.tar.gz"
|
local tarball="keisei-${target}.tar.gz"
|
||||||
local url="https://github.com/KeiSei84/KeiSeiKit-1.0/releases/latest/download/${tarball}"
|
local url="https://github.com/KeiSeiLab/KeiSeiKit-1.0/releases/latest/download/${tarball}"
|
||||||
local tmp
|
local tmp
|
||||||
tmp="$(mktemp -d -t keisei-prebuild-XXXX 2>/dev/null)" || return 1
|
tmp="$(mktemp -d -t keisei-prebuild-XXXX 2>/dev/null)" || return 1
|
||||||
command -v curl >/dev/null 2>&1 || { rm -rf "$tmp"; return 1; }
|
command -v curl >/dev/null 2>&1 || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ setup_target_dirs() {
|
||||||
"$AGENTS_DIR/_generated" \
|
"$AGENTS_DIR/_generated" \
|
||||||
"$HOOKS_DIR" \
|
"$HOOKS_DIR" \
|
||||||
"$SKILLS_DIR/new-agent" \
|
"$SKILLS_DIR/new-agent" \
|
||||||
"$HOME_DIR/.claude/memory"
|
"$HOME_DIR/.claude/memory" \
|
||||||
|
"$HOME_DIR/.claude/scripts"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Write a stub MEMORY.md if the user has no index yet. We never overwrite.
|
# Write a stub MEMORY.md if the user has no index yet. We never overwrite.
|
||||||
|
|
@ -65,6 +66,22 @@ copy_sleep_scripts() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Pure-bash scripts → ~/.claude/scripts/ (tamagotchi renderer + state updater,
|
||||||
|
# kei-message mailbox CLI, any future scripts). Zero binary deps, always
|
||||||
|
# available regardless of profile. statusLine + pet-update + mailbox-inject
|
||||||
|
# hooks are wired into settings.json by the settings-snippet merge (lib-hooks.sh).
|
||||||
|
copy_pet_scripts() {
|
||||||
|
local src dst="$HOME_DIR/.claude/scripts" name
|
||||||
|
[ -d "$KIT_DIR/scripts" ] || return 0
|
||||||
|
mkdir -p "$dst"
|
||||||
|
for src in "$KIT_DIR/scripts/"*.sh; do
|
||||||
|
[ -f "$src" ] || continue
|
||||||
|
name="$(basename "$src")"
|
||||||
|
cp -f "$src" "$dst/$name"
|
||||||
|
chmod +x "$dst/$name"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# Clean slate: drop every shell .sh + rust crate dir from the installed set.
|
# Clean slate: drop every shell .sh + rust crate dir from the installed set.
|
||||||
# FAST (no per-rust rebuild). A single regenerate_rust_workspace at the end
|
# FAST (no per-rust rebuild). A single regenerate_rust_workspace at the end
|
||||||
# of install_primitives handles the final state.
|
# of install_primitives handles the final state.
|
||||||
|
|
@ -99,6 +116,7 @@ install_profile_primitives() {
|
||||||
run_primitives_phase() {
|
run_primitives_phase() {
|
||||||
copy_primitives_meta
|
copy_primitives_meta
|
||||||
copy_sleep_scripts
|
copy_sleep_scripts
|
||||||
|
copy_pet_scripts
|
||||||
say "resolving primitives for profile=$PROFILE"
|
say "resolving primitives for profile=$PROFILE"
|
||||||
clean_slate_primitives
|
clean_slate_primitives
|
||||||
install_profile_primitives
|
install_profile_primitives
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,9 @@ print_summary() {
|
||||||
$AGENTS_DIR/_assembler/target/release/assemble --validate
|
$AGENTS_DIR/_assembler/target/release/assemble --validate
|
||||||
./install.sh --list # show installed primitives
|
./install.sh --list # show installed primitives
|
||||||
|
|
||||||
To create a new project-specialist agent:
|
To set up agents for ALL your projects (scan stack + create one per project):
|
||||||
|
/onboard ~/Projects/*
|
||||||
|
Or create a single project-specialist agent:
|
||||||
/new-agent
|
/new-agent
|
||||||
|
|
||||||
==========================================================================
|
==========================================================================
|
||||||
|
|
@ -93,7 +95,9 @@ EOF
|
||||||
$AGENTS_DIR/_assembler/target/release/assemble --validate
|
$AGENTS_DIR/_assembler/target/release/assemble --validate
|
||||||
./install.sh --list # show installed primitives
|
./install.sh --list # show installed primitives
|
||||||
|
|
||||||
To create a new project-specialist agent:
|
To set up agents for ALL your projects (scan stack + create one per project):
|
||||||
|
/onboard ~/Projects/*
|
||||||
|
Or create a single project-specialist agent:
|
||||||
/new-agent
|
/new-agent
|
||||||
|
|
||||||
==========================================================================
|
==========================================================================
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
run_sleep_wizard() {
|
run_sleep_wizard() {
|
||||||
local sleep_helper="$AGENTS_DIR/_primitives/kei-sleep-setup.sh"
|
local sleep_helper="$AGENTS_DIR/_primitives/kei-sleep-setup.sh"
|
||||||
if [[ -x "$sleep_helper" ]] && [ -t 0 ] && [ -t 1 ]; then
|
if [[ -x "$sleep_helper" ]] && [ -t 0 ]; then # stdin only; not -t 1 (curl|bash tees stdout)
|
||||||
say "running sleep-sync setup helper"
|
say "running sleep-sync setup helper"
|
||||||
"$sleep_helper" || warn "sleep-sync setup did not complete — re-run via /sleep-setup"
|
"$sleep_helper" || warn "sleep-sync setup did not complete — re-run via /sleep-setup"
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
"$schema": "https://json.schemastore.org/claude-code-plugin.json",
|
"$schema": "https://json.schemastore.org/claude-code-plugin.json",
|
||||||
"name": "keisei",
|
"name": "keisei",
|
||||||
"displayName": "KeiSei",
|
"displayName": "KeiSei",
|
||||||
"description": "Constructor Pattern agent substrate — 59 agents, 67 skills, 39 hooks, 86 blocks. Rust primitives via classic ./install.sh.",
|
"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.38.0",
|
"version": "0.45.0",
|
||||||
"homepage": "https://keigit.com/keisei/KeiSeiKit-1.0",
|
"homepage": "https://keisei.app",
|
||||||
"repository": "https://keigit.com/keisei/KeiSeiKit-1.0.git",
|
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Denis Parfionovich",
|
"name": "Denis Parfionovich",
|
||||||
"email": "parfionovich@keilab.io"
|
"email": "parfionovich@keilab.io"
|
||||||
|
|
|
||||||
281
scripts/kei-agent-cli.sh
Executable file
281
scripts/kei-agent-cli.sh
Executable file
|
|
@ -0,0 +1,281 @@
|
||||||
|
#!/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"
|
||||||
62
scripts/kei-configure.sh
Normal file
62
scripts/kei-configure.sh
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# kei-configure — re-pick hook packs + stack profile after install, without a
|
||||||
|
# full reinstall. Updates ~/.claude/config/onboarding.toml and re-applies the
|
||||||
|
# hook selection to settings.json (adds newly selected hooks, removes deselected
|
||||||
|
# kit hooks, leaves your own hooks untouched). Agent-set changes apply on the
|
||||||
|
# next `./install.sh`.
|
||||||
|
#
|
||||||
|
# Invoked via `kei configure`. Interactive (needs a terminal).
|
||||||
|
set -u
|
||||||
|
set -o pipefail 2>/dev/null || true
|
||||||
|
|
||||||
|
HOME_DIR="${HOME:?HOME not set}"
|
||||||
|
KIT_DIR="$(cat "$HOME_DIR/.claude/.kei-kit-dir" 2>/dev/null || true)"
|
||||||
|
if [ -z "$KIT_DIR" ] || [ ! -d "$KIT_DIR/install" ]; then
|
||||||
|
echo "kei configure: KeiSeiKit checkout not found." >&2
|
||||||
|
echo " (expected its path in ~/.claude/.kei-kit-dir; re-run ./install.sh from your checkout)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
echo "kei configure: interactive only — run it from a terminal." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LIB_DIR="$KIT_DIR/install"
|
||||||
|
MANIFEST="$KIT_DIR/_primitives/MANIFEST.toml"
|
||||||
|
PACKS_TOML="$KIT_DIR/_primitives/hook-packs.toml"
|
||||||
|
ONBOARDING_CONFIG="$HOME_DIR/.claude/config/onboarding.toml"
|
||||||
|
export HOME_DIR KIT_DIR LIB_DIR MANIFEST PACKS_TOML ONBOARDING_CONFIG
|
||||||
|
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$LIB_DIR/lib-log.sh"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$LIB_DIR/lib-backup.sh"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$LIB_DIR/lib-profile.sh"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$LIB_DIR/lib-packs.sh"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$LIB_DIR/lib-hooks.sh"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$LIB_DIR/lib-onboarding-ui.sh"
|
||||||
|
|
||||||
|
ONBOARDING_STACK=""
|
||||||
|
ONBOARDING_PACKS=""
|
||||||
|
onboarding_pick_stack
|
||||||
|
|
||||||
|
# Update only stack_profile/enabled_packs in onboarding.toml; preserve the rest.
|
||||||
|
mkdir -p "$(dirname "$ONBOARDING_CONFIG")"
|
||||||
|
touch "$ONBOARDING_CONFIG"
|
||||||
|
_tmp="$(mktemp)"
|
||||||
|
grep -vE '^(stack_profile|enabled_packs)[[:space:]]*=' "$ONBOARDING_CONFIG" > "$_tmp" 2>/dev/null || true
|
||||||
|
{
|
||||||
|
printf 'stack_profile = "%s"\n' "$ONBOARDING_STACK"
|
||||||
|
printf 'enabled_packs = "%s"\n' "$ONBOARDING_PACKS"
|
||||||
|
} >> "$_tmp"
|
||||||
|
mv "$_tmp" "$ONBOARDING_CONFIG"
|
||||||
|
|
||||||
|
# Re-apply hooks: prune kit-owned entries, merge the newly selected set.
|
||||||
|
activate_hooks
|
||||||
|
|
||||||
|
say "reconfigured: stack=$ONBOARDING_STACK packs=${ONBOARDING_PACKS:-none}"
|
||||||
|
say " settings.json hooks updated. Agent-set changes apply on the next ./install.sh."
|
||||||
230
scripts/kei-limits.sh
Executable file
230
scripts/kei-limits.sh
Executable file
|
|
@ -0,0 +1,230 @@
|
||||||
|
#!/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}"
|
||||||
52
scripts/kei-mcp-wire-agy.sh
Executable file
52
scripts/kei-mcp-wire-agy.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/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
|
||||||
42
scripts/kei-mcp-wire-claude.sh
Executable file
42
scripts/kei-mcp-wire-claude.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/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
|
||||||
52
scripts/kei-mcp-wire-copilot.sh
Executable file
52
scripts/kei-mcp-wire-copilot.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/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'"
|
||||||
77
scripts/kei-mcp-wire-grok.sh
Executable file
77
scripts/kei-mcp-wire-grok.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/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."
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue