M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
//! Validation of [`ToolConfigEntry`](crate::ToolConfigEntry) fields,
|
||||
//! shared so the backend's save-time check cannot drift from what the
|
||||
//! tools server enforces at finalize/bind. Errors carry the offending input
|
||||
//! so callers can render gRPC violations without re-parsing.
|
||||
|
||||
use kigi_tool_protocol::ToolId;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Why a [`ToolConfigEntry`](crate::ToolConfigEntry) is invalid.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ToolConfigEntryErrorKind {
|
||||
/// Not valid JSON. Includes an explicitly-set empty string: proto3
|
||||
/// `optional` tracks presence, so `Some("")` is rejected, not unset.
|
||||
ParamsJsonParse { error: String, raw: String },
|
||||
/// Valid JSON but not an object.
|
||||
ParamsJsonNotObject { value: Value },
|
||||
/// `name_override` is not a valid `ToolId` (charset/length contract).
|
||||
NameOverrideInvalid { name: String, error: String },
|
||||
}
|
||||
|
||||
/// Validation error for one entry in a tool-config list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolConfigEntryError {
|
||||
pub index: usize,
|
||||
pub tool_id: String,
|
||||
pub kind: ToolConfigEntryErrorKind,
|
||||
}
|
||||
|
||||
impl ToolConfigEntryError {
|
||||
/// Request field path of the failing field, e.g. `tools[3].params_json`.
|
||||
pub fn field_path(&self) -> String {
|
||||
match self.kind {
|
||||
ToolConfigEntryErrorKind::ParamsJsonParse { .. }
|
||||
| ToolConfigEntryErrorKind::ParamsJsonNotObject { .. } => {
|
||||
format!("tools[{}].params_json", self.index)
|
||||
}
|
||||
ToolConfigEntryErrorKind::NameOverrideInvalid { .. } => {
|
||||
format!("tools[{}].name_override", self.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolConfigEntryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match &self.kind {
|
||||
ToolConfigEntryErrorKind::ParamsJsonParse { error, .. } => write!(
|
||||
f,
|
||||
"{}: {} failed to parse JSON: {error}",
|
||||
self.tool_id,
|
||||
self.field_path()
|
||||
),
|
||||
ToolConfigEntryErrorKind::ParamsJsonNotObject { .. } => write!(
|
||||
f,
|
||||
"{}: {} must be a JSON object",
|
||||
self.tool_id,
|
||||
self.field_path()
|
||||
),
|
||||
ToolConfigEntryErrorKind::NameOverrideInvalid { name, error } => write!(
|
||||
f,
|
||||
"{}: {} is not a valid tool name ({name:?}): {error}",
|
||||
self.tool_id,
|
||||
self.field_path()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ToolConfigEntryError {}
|
||||
|
||||
/// Parse and validate a `params_json`, returning the decoded object (or
|
||||
/// `None` when unset). `index`/`tool_id` are only used for error reporting.
|
||||
pub fn parse_params_json(
|
||||
index: usize,
|
||||
tool_id: &str,
|
||||
params_json: Option<&str>,
|
||||
) -> Result<Option<Map<String, Value>>, ToolConfigEntryError> {
|
||||
let Some(raw) = params_json else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value: Value = serde_json::from_str(raw).map_err(|err| ToolConfigEntryError {
|
||||
index,
|
||||
tool_id: tool_id.to_owned(),
|
||||
kind: ToolConfigEntryErrorKind::ParamsJsonParse {
|
||||
error: err.to_string(),
|
||||
raw: raw.to_owned(),
|
||||
},
|
||||
})?;
|
||||
match value {
|
||||
Value::Object(object) => Ok(Some(object)),
|
||||
other => Err(ToolConfigEntryError {
|
||||
index,
|
||||
tool_id: tool_id.to_owned(),
|
||||
kind: ToolConfigEntryErrorKind::ParamsJsonNotObject { value: other },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a `name_override` against the `ToolId` charset/length contract,
|
||||
/// mirroring [`parse_params_json`] as the shared source of truth.
|
||||
pub fn validate_name_override(
|
||||
index: usize,
|
||||
tool_id: &str,
|
||||
name_override: Option<&str>,
|
||||
) -> Result<(), ToolConfigEntryError> {
|
||||
let Some(name) = name_override else {
|
||||
return Ok(());
|
||||
};
|
||||
ToolId::new(name).map_err(|err| ToolConfigEntryError {
|
||||
index,
|
||||
tool_id: tool_id.to_owned(),
|
||||
kind: ToolConfigEntryErrorKind::NameOverrideInvalid {
|
||||
name: name.to_owned(),
|
||||
error: err.to_string(),
|
||||
},
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the first entry whose `id` is not in `allowed_ids`, as
|
||||
/// `(index, id)`, or `None` when all ids are allowed.
|
||||
///
|
||||
/// Pure so backend save-time validation and any future consumer share one rule.
|
||||
pub fn first_unknown_tool_id<'a>(
|
||||
entries: &'a [crate::ToolConfigEntry],
|
||||
allowed_ids: &std::collections::HashSet<String>,
|
||||
) -> Option<(usize, &'a str)> {
|
||||
entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, entry)| !allowed_ids.contains(&entry.id))
|
||||
.map(|(index, entry)| (index, entry.id.as_str()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unset_params_is_ok_none() {
|
||||
assert_eq!(parse_params_json(0, "GrokBuild:grep", None), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_object_is_returned() {
|
||||
let parsed = parse_params_json(0, "GrokBuild:grep", Some(r#"{"max_results":50}"#)).unwrap();
|
||||
assert_eq!(
|
||||
parsed,
|
||||
Some(
|
||||
serde_json::json!({"max_results": 50})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_is_a_parse_error() {
|
||||
let err = parse_params_json(3, "GrokBuild:grep", Some("")).unwrap_err();
|
||||
assert_eq!(err.index, 3);
|
||||
assert_eq!(err.field_path(), "tools[3].params_json");
|
||||
assert!(matches!(
|
||||
err.kind,
|
||||
ToolConfigEntryErrorKind::ParamsJsonParse { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_is_a_parse_error() {
|
||||
let err = parse_params_json(0, "t", Some("{not json")).unwrap_err();
|
||||
assert!(matches!(
|
||||
err.kind,
|
||||
ToolConfigEntryErrorKind::ParamsJsonParse { raw, .. } if raw == "{not json"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_object_json_is_rejected() {
|
||||
let err = parse_params_json(1, "t", Some("[1,2,3]")).unwrap_err();
|
||||
assert!(matches!(
|
||||
err.kind,
|
||||
ToolConfigEntryErrorKind::ParamsJsonNotObject {
|
||||
value: Value::Array(_)
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_override_unset_or_valid_is_ok() {
|
||||
assert_eq!(validate_name_override(0, "GrokBuild:grep", None), Ok(()));
|
||||
for name in ["search", "GrokBuild:grep", "a-b_C9"] {
|
||||
assert_eq!(
|
||||
validate_name_override(0, "GrokBuild:grep", Some(name)),
|
||||
Ok(()),
|
||||
"name={name:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_override_outside_charset_is_rejected() {
|
||||
for name in ["has space", "", "a:b:c", "dot.name"] {
|
||||
let err = validate_name_override(2, "GrokBuild:grep", Some(name)).unwrap_err();
|
||||
assert_eq!(err.index, 2, "name={name:?}");
|
||||
assert_eq!(err.field_path(), "tools[2].name_override");
|
||||
assert!(
|
||||
matches!(
|
||||
&err.kind,
|
||||
ToolConfigEntryErrorKind::NameOverrideInvalid { name: n, .. } if n == name
|
||||
),
|
||||
"name={name:?} kind={:?}",
|
||||
err.kind
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn entry(id: &str) -> crate::ToolConfigEntry {
|
||||
crate::ToolConfigEntry {
|
||||
id: id.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed(ids: &[&str]) -> std::collections::HashSet<String> {
|
||||
ids.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_ids_present_returns_none() {
|
||||
let entries = [entry("GrokBuild:grep"), entry("GrokBuild:read_file")];
|
||||
let allowed = allowed(&["GrokBuild:grep", "GrokBuild:read_file", "GrokBuild:bash"]);
|
||||
assert_eq!(first_unknown_tool_id(&entries, &allowed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_entries_returns_none() {
|
||||
assert_eq!(
|
||||
first_unknown_tool_id(&[], &allowed(&["GrokBuild:grep"])),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_unknown_id_is_returned_with_index() {
|
||||
let entries = [
|
||||
entry("GrokBuild:grep"),
|
||||
entry("GrokBuild:nonexistent"),
|
||||
entry("GrokBuild:also_missing"),
|
||||
];
|
||||
let allowed = allowed(&["GrokBuild:grep"]);
|
||||
assert_eq!(
|
||||
first_unknown_tool_id(&entries, &allowed),
|
||||
Some((1, "GrokBuild:nonexistent"))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user