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).
190 lines
6.3 KiB
Rust
190 lines
6.3 KiB
Rust
// todo: add support for signal handling
|
|
use std::process::Stdio;
|
|
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::process::Command;
|
|
use tokio::time;
|
|
|
|
use crate::terminal::runner::{
|
|
AsyncTerminalRunner, TerminalError, TerminalRunRequest, TerminalRunResult,
|
|
};
|
|
|
|
pub struct LocalTerminalRunner;
|
|
|
|
async fn read_stream(mut stream: impl AsyncReadExt + Unpin) -> Vec<u8> {
|
|
let mut buffer = Vec::new();
|
|
let _ = stream.read_to_end(&mut buffer).await;
|
|
buffer
|
|
}
|
|
|
|
/// Truncate buffer to keep only the last `limit` bytes (drops oldest bytes).
|
|
/// Returns true if truncation occurred.
|
|
///
|
|
/// This function ensures we don't split UTF-8 characters when truncating
|
|
/// by using char_indices to find a valid character boundary.
|
|
fn truncate_buffer(buf: &mut Vec<u8>, limit: usize) -> bool {
|
|
if buf.len() > limit {
|
|
// Convert to string to work with character boundaries
|
|
let s = String::from_utf8_lossy(buf);
|
|
let excess = buf.len().saturating_sub(limit);
|
|
|
|
// Find the first char boundary at or after `excess` bytes
|
|
let start_idx = s
|
|
.char_indices()
|
|
.find(|(i, _)| *i >= excess)
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(s.len());
|
|
|
|
// Slice from that boundary and update buffer
|
|
*buf = s[start_idx..].as_bytes().to_vec();
|
|
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl AsyncTerminalRunner for LocalTerminalRunner {
|
|
async fn run(&self, request: TerminalRunRequest) -> Result<TerminalRunResult, TerminalError> {
|
|
// Build and spawn the command via the platform shell.
|
|
#[cfg(unix)]
|
|
let mut cmd = {
|
|
let mut c = Command::new(crate::terminal::default_shell_path());
|
|
c.arg("-lc").arg(&request.command);
|
|
c
|
|
};
|
|
#[cfg(not(unix))]
|
|
let mut cmd = {
|
|
let inv = kigi_config::shell::shell_command_argv(&request.command);
|
|
let mut c = Command::new(inv.program);
|
|
c.args(&inv.args).envs(inv.env);
|
|
c
|
|
};
|
|
cmd.current_dir(&request.cwd)
|
|
.envs(&request.env)
|
|
.envs(crate::terminal::pager_env())
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
// Detach from the controlling terminal so child processes
|
|
// (e.g. GPG pinentry) cannot open /dev/tty and corrupt the TUI.
|
|
kigi_tools::util::detach_command(&mut cmd);
|
|
|
|
let mut child = cmd
|
|
.spawn()
|
|
.map_err(|e| TerminalError::Other(format!("Failed to start shell: {e}")))?;
|
|
|
|
let stdout = child
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| TerminalError::Other("Failed to capture stdout".into()))?;
|
|
let stderr = child
|
|
.stderr
|
|
.take()
|
|
.ok_or_else(|| TerminalError::Other("Failed to capture stderr".into()))?;
|
|
|
|
let stdout_task = tokio::spawn(read_stream(stdout));
|
|
let stderr_task = tokio::spawn(read_stream(stderr));
|
|
|
|
let mut timed_out = false;
|
|
|
|
let wait_result = time::timeout(request.timeout, child.wait()).await;
|
|
let exit_status = match wait_result {
|
|
Ok(status_res) => status_res
|
|
.map_err(|e| TerminalError::Other(format!("Failed to wait for process: {e}")))?,
|
|
Err(_) => {
|
|
timed_out = true;
|
|
if let Err(e) = child.start_kill() {
|
|
tracing::warn!("Failed to kill timed-out process: {e}");
|
|
}
|
|
child.wait().await.map_err(|e| {
|
|
TerminalError::Other(format!("Failed to wait for killed process: {e}"))
|
|
})?
|
|
}
|
|
};
|
|
|
|
let stdout_result = stdout_task.await.unwrap_or_else(|_| Vec::new());
|
|
let stderr_result = stderr_task.await.unwrap_or_else(|_| Vec::new());
|
|
|
|
// Combine stdout and stderr, then truncate if needed
|
|
let mut combined = stdout_result;
|
|
combined.extend(stderr_result);
|
|
let truncated = truncate_buffer(&mut combined, request.output_byte_limit);
|
|
|
|
let combined_output = String::from_utf8_lossy(&combined).into_owned();
|
|
|
|
Ok(TerminalRunResult {
|
|
combined_output,
|
|
exit_code: exit_status.code(),
|
|
truncated,
|
|
signal: None,
|
|
timed_out,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::terminal::DEFAULT_OUTPUT_BYTE_LIMIT;
|
|
use crate::terminal::runner::TerminalRunRequest;
|
|
use kigi_paths::AbsPathBuf;
|
|
use std::collections::HashMap;
|
|
|
|
fn make_request(command: &str) -> TerminalRunRequest {
|
|
TerminalRunRequest {
|
|
tool_call_id: agent_client_protocol::ToolCallId::new("test"),
|
|
command: command.to_string(),
|
|
cwd: AbsPathBuf::new(std::env::current_dir().unwrap()).unwrap(),
|
|
env: HashMap::new(),
|
|
timeout: std::time::Duration::from_secs(10),
|
|
output_byte_limit: DEFAULT_OUTPUT_BYTE_LIMIT,
|
|
stream: false,
|
|
output_file: None,
|
|
}
|
|
}
|
|
|
|
/// Verify that `detach_from_tty` prevents child processes from opening
|
|
/// `/dev/tty`. After setsid(), the child has no controlling terminal.
|
|
#[tokio::test]
|
|
#[cfg(unix)]
|
|
async fn test_child_cannot_open_dev_tty() {
|
|
// Skip in CI / environments without a controlling terminal.
|
|
if std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.open("/dev/tty")
|
|
.is_err()
|
|
{
|
|
eprintln!("skipping: no controlling terminal");
|
|
return;
|
|
}
|
|
|
|
let result = LocalTerminalRunner
|
|
.run(make_request(
|
|
"(exec 3>/dev/tty && echo ATTACHED || echo DETACHED) 2>/dev/null",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
result.combined_output.trim(),
|
|
"DETACHED",
|
|
"child process should not be able to open /dev/tty after detach_from_tty()"
|
|
);
|
|
}
|
|
|
|
/// Basic regression: commands still produce output and exit normally.
|
|
#[tokio::test]
|
|
async fn test_basic_command_output() {
|
|
let result = LocalTerminalRunner
|
|
.run(make_request("echo hello"))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result.combined_output.trim(), "hello");
|
|
assert_eq!(result.exit_code, Some(0));
|
|
}
|
|
}
|