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,434 @@
|
||||
//! Login, logout, account switching, and auth-code submission dispatchers.
|
||||
|
||||
use super::ctx::{restore_auth_return_view, show_welcome};
|
||||
use super::queue::maybe_drain_queue;
|
||||
use super::router::dispatch;
|
||||
use super::session::lifecycle::{clear_startup_actions, drain_startup_actions};
|
||||
use crate::app::actions::{Action, Effect};
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `/logout` -- ask the shell to clear auth, then return to the login screen.
|
||||
pub(super) fn dispatch_logout(_app: &mut AppView) -> Vec<Effect> {
|
||||
vec![Effect::Logout]
|
||||
}
|
||||
|
||||
/// Ensure `login_method_id` is populated from stored auth methods.
|
||||
/// On the eager-auth path (cached token), login_method_id is never set
|
||||
/// because the user skipped the login screen.
|
||||
///
|
||||
/// Does **not** invent `grok.com` when no interactive method is advertised
|
||||
/// (e.g. `preferred_method=api_key` with no key — empty `auth_methods`).
|
||||
/// Callers already surface "No login method available" when this leaves
|
||||
/// `login_method_id` unset.
|
||||
pub(super) fn ensure_login_method(app: &mut AppView) {
|
||||
if app.login_method_id.is_some() {
|
||||
return;
|
||||
}
|
||||
let (label, method_id, start_mode) =
|
||||
crate::acp::find_interactive_login_method(&app.auth_methods);
|
||||
if let Some(id) = method_id {
|
||||
app.login_label = label;
|
||||
app.login_method_id = Some(id);
|
||||
app.auth_start_mode = match start_mode {
|
||||
crate::acp::AuthStartMode::Pending => AuthMode::Pending,
|
||||
crate::acp::AuthStartMode::Command => AuthMode::Command,
|
||||
};
|
||||
}
|
||||
// No interactive method: leave login_method_id unset (fail-closed).
|
||||
}
|
||||
|
||||
/// Error when no interactive login method is available (empty auth_methods,
|
||||
/// e.g. `preferred_method=api_key` with no credentials). Prefer the shell's
|
||||
/// pin-unavailable copy when the list is empty.
|
||||
fn no_login_method_error(app: &AppView) -> String {
|
||||
if app.auth_methods.is_empty() {
|
||||
kigi_shell::agent::auth_method::PREFERRED_API_KEY_UNAVAILABLE.to_string()
|
||||
} else {
|
||||
"No login method available".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Log out, then start a new login flow in a single sequential task.
|
||||
pub(super) fn dispatch_switch_account(app: &mut AppView) -> Vec<Effect> {
|
||||
ensure_login_method(app);
|
||||
|
||||
let Some(method_id) = app.login_method_id.clone() else {
|
||||
app.auth_state = AuthState::Pending {
|
||||
error: Some(no_login_method_error(app)),
|
||||
};
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let request_seq = app.next_auth_request_seq;
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: app.auth_start_mode,
|
||||
};
|
||||
|
||||
vec![
|
||||
Effect::SwitchAccount {
|
||||
request_seq,
|
||||
method_id,
|
||||
use_oauth: app.auth_use_oauth,
|
||||
},
|
||||
Effect::PollAuthUrl { request_seq },
|
||||
]
|
||||
}
|
||||
|
||||
/// Scan the trailing run of session-event / system blocks for a
|
||||
/// [`SessionEvent::ReAuthRequired`] prompt. Used by the `PromptResponse`
|
||||
/// handler to suppress the redundant "Turn failed" block after a 401 — the
|
||||
/// re-auth prompt is pushed by the `RetryState` handler, which runs first.
|
||||
pub(super) fn scrollback_has_recent_reauth_prompt(
|
||||
scrollback: &crate::scrollback::state::ScrollbackState,
|
||||
) -> bool {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
for idx in (0..scrollback.len()).rev() {
|
||||
match scrollback.entry(idx).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(ev)) => {
|
||||
if matches!(ev.event, SessionEvent::ReAuthRequired) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Tolerate interleaved system messages in the trailing run.
|
||||
Some(RenderBlock::System(_)) => {}
|
||||
// Stop at the first substantive block: any re-auth prompt for
|
||||
// this turn lives in the trailing events pushed just before the
|
||||
// PromptResponse arrived.
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// True if the trailing run of session/system blocks contains a terminal
|
||||
/// context-overflow block ([`SessionEvent::ContextTooLarge`] or `CompactionFailed`).
|
||||
/// Lets `PromptResponse` suppress the redundant `TurnFailed`, mirroring reauth.
|
||||
pub(super) fn scrollback_has_recent_context_too_large(
|
||||
scrollback: &crate::scrollback::state::ScrollbackState,
|
||||
) -> bool {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
for idx in (0..scrollback.len()).rev() {
|
||||
match scrollback.entry(idx).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(ev)) => {
|
||||
if matches!(
|
||||
ev.event,
|
||||
SessionEvent::ContextTooLarge | SessionEvent::CompactionFailed { .. }
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Tolerate interleaved system messages in the trailing run.
|
||||
Some(RenderBlock::System(_)) => {}
|
||||
// Stop at the first substantive block.
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Strip the trailing run of auth-error blocks — the `ReAuthRequired`
|
||||
/// prompt plus any stale `RetryFailed` / `TurnFailed` — from an agent's
|
||||
/// scrollback. Called after a successful mid-session re-auth so the prompt
|
||||
/// disappears once the user returns to the session. Mirrors the
|
||||
/// credit-limit upsell's stale-block strip.
|
||||
pub(super) fn strip_trailing_auth_error_blocks(agent: &mut AgentView) {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut to_remove = Vec::new();
|
||||
for idx in (0..agent.scrollback.len()).rev() {
|
||||
match agent.scrollback.entry(idx).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(ev))
|
||||
if matches!(
|
||||
&ev.event,
|
||||
SessionEvent::ReAuthRequired
|
||||
| SessionEvent::RetryFailed { .. }
|
||||
| SessionEvent::TurnFailed { .. }
|
||||
) =>
|
||||
{
|
||||
to_remove.push(idx);
|
||||
}
|
||||
// Skip over other trailing session-event / system blocks.
|
||||
Some(RenderBlock::SessionEvent(_) | RenderBlock::System(_)) => continue,
|
||||
// Stop at the first substantive block.
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
for idx in to_remove {
|
||||
agent.scrollback.remove_from(idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start an interactive login flow. Triggered by pressing 'l' on the
|
||||
/// welcome screen or by the `/login` slash command.
|
||||
///
|
||||
/// When invoked mid-session (the active view is an agent/dashboard rather
|
||||
/// than the welcome screen), the auth UI — including the external auth
|
||||
/// provider's sign-in URL and status — is only rendered by the welcome
|
||||
/// view. We therefore stash the caller's view in `auth_return_view` and
|
||||
/// switch to `Welcome` so the flow is actually visible; the prior view is
|
||||
/// restored once auth completes or is cancelled. Without this, `/login`
|
||||
/// with an external auth provider configured appeared to do nothing.
|
||||
pub(super) fn dispatch_login(app: &mut AppView) -> Vec<Effect> {
|
||||
ensure_login_method(app);
|
||||
let Some(method_id) = app.login_method_id.clone() else {
|
||||
app.auth_state = AuthState::Pending {
|
||||
error: Some(no_login_method_error(app)),
|
||||
};
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Surface the auth UI when triggered from inside a session. `show_welcome`
|
||||
// resets ephemeral state here, covering the AuthComplete / cancel-login
|
||||
// fallbacks too (`auth_return_view` is only ever set here).
|
||||
if !matches!(app.active_view, ActiveView::Welcome) {
|
||||
app.auth_return_view = Some(app.active_view);
|
||||
show_welcome(app);
|
||||
}
|
||||
|
||||
let request_seq = app.next_auth_request_seq;
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: app.auth_start_mode,
|
||||
};
|
||||
|
||||
vec![
|
||||
Effect::Authenticate {
|
||||
request_seq,
|
||||
method_id,
|
||||
use_oauth: app.auth_use_oauth,
|
||||
force_interactive: true,
|
||||
},
|
||||
Effect::PollAuthUrl { request_seq },
|
||||
]
|
||||
}
|
||||
|
||||
/// Cancel a login that was started from inside a session and restore the
|
||||
/// caller's view. Only meaningful when `auth_return_view` is set (a
|
||||
/// mid-session `/login` or 401 re-auth prompt). Any in-flight auth task is
|
||||
/// left to finish in the background — its `AuthComplete`/`AuthFailed`
|
||||
/// result is ignored because we move `auth_state` out of `Authenticating`
|
||||
/// here (the request-seq/state guard in those handlers drops stale results)
|
||||
/// and bump the seq so a fresh login does not collide.
|
||||
pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec<Effect> {
|
||||
let Some(return_view) = app.auth_return_view.take() else {
|
||||
return vec![];
|
||||
};
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_state = AuthState::Done;
|
||||
app.auth_show_raw_url = false;
|
||||
app.auth_code_input.clear();
|
||||
restore_auth_return_view(app, return_view);
|
||||
// The user bailed out of re-auth — drop stashed prompts and strip the
|
||||
// stale re-auth prompt from scrollback (on all agents: the login may
|
||||
// have been started from the dashboard). Clearing the stash alone is
|
||||
// not enough: a leftover `ReAuthRequired` block would let a later
|
||||
// `PromptResponse` re-detect it via `scrollback_has_recent_reauth_prompt`
|
||||
// and re-stash the prompt, so a subsequent unrelated login could
|
||||
// silently resubmit it. Mirrors the strip in the `AuthComplete` path.
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.reauth_stashed_prompt = None;
|
||||
strip_trailing_auth_error_blocks(agent);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// User submitted a manually-pasted auth token in loopback mode.
|
||||
pub(super) fn dispatch_submit_auth_code(app: &mut AppView, code: String) -> Vec<Effect> {
|
||||
let request_seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => return vec![],
|
||||
};
|
||||
|
||||
vec![Effect::SubmitAuthCode { request_seq, code }]
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_auth_complete(
|
||||
app: &mut AppView,
|
||||
request_seq: u64,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> Vec<Effect> {
|
||||
if let AuthState::Authenticating {
|
||||
request_seq: current_seq,
|
||||
..
|
||||
} = &app.auth_state
|
||||
&& *current_seq == request_seq
|
||||
{
|
||||
if let Some(meta_val) = meta.as_ref()
|
||||
&& let Ok(auth_meta) =
|
||||
serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val.clone())
|
||||
{
|
||||
app.apply_auth_meta(&auth_meta);
|
||||
}
|
||||
|
||||
app.auth_state = AuthState::Done;
|
||||
app.auth_show_raw_url = false;
|
||||
app.welcome_prompt_focused = !app.is_access_blocked();
|
||||
app.auth_code_input.clear();
|
||||
|
||||
// Mid-session re-auth (`/login` or a 401 prompt): restore the
|
||||
// view the user was on instead of running the startup
|
||||
// load-session flow. The session state lives in `app.agents`,
|
||||
// independent of `active_view`, so it is preserved across the
|
||||
// auth detour.
|
||||
if let Some(return_view) = app.auth_return_view.take() {
|
||||
restore_auth_return_view(app, return_view);
|
||||
// Mid-session re-auth returns to the existing session, NOT
|
||||
// the startup flow, so discard any deferred startup stash
|
||||
// (e.g. an incidental `Ctrl+N` pressed during /login that the
|
||||
// chokepoint deferred) rather than leaving it to fire later.
|
||||
clear_startup_actions(app);
|
||||
// Re-auth succeeded — hide the now-stale re-auth prompt
|
||||
// (and any trailing error blocks) so the user returns to
|
||||
// a clean session. Mirrors the credit-limit upsell's
|
||||
// stale-block strip.
|
||||
// Auth is global, so handle every agent (the login may
|
||||
// have been started from the dashboard, not the agent
|
||||
// that 401'd).
|
||||
let mut retry_effects = Vec::new();
|
||||
for agent in app.agents.values_mut() {
|
||||
strip_trailing_auth_error_blocks(agent);
|
||||
// Auto-resubmit the prompt that failed on the expired
|
||||
// login so the user doesn't have to retype it. The
|
||||
// user couldn't have queued another prompt during the
|
||||
// auth detour, so a plain front-enqueue + drain is safe.
|
||||
if let Some(prompt) = agent.reauth_stashed_prompt.take() {
|
||||
agent.scrollback.push_block(RenderBlock::system(
|
||||
"Re-authenticated. Retrying\u{2026}".to_string(),
|
||||
));
|
||||
agent.session.enqueue_in_flight_prompt_front(prompt);
|
||||
retry_effects.extend(maybe_drain_queue(agent));
|
||||
}
|
||||
}
|
||||
let mut effects = dispatch(Action::RequestBundleStatus, app);
|
||||
if app.usage_visible {
|
||||
effects.push(Effect::FetchAppBilling);
|
||||
}
|
||||
effects.extend(retry_effects);
|
||||
return effects;
|
||||
}
|
||||
|
||||
// status only; shell auto-syncs post-auth
|
||||
let mut effects = dispatch(Action::RequestBundleStatus, app);
|
||||
|
||||
// Start auto-checking subscription if gated.
|
||||
// Check immediately (don't wait 5s) then schedule the timer.
|
||||
if !app.has_access() {
|
||||
app.paywall_check_started = Some(std::time::Instant::now());
|
||||
effects.push(Effect::CheckSubscription { verify: None });
|
||||
effects.push(Effect::SchedulePaywallCheck);
|
||||
}
|
||||
// Fetch billing so the welcome screen can show a credit warning.
|
||||
if app.usage_visible {
|
||||
effects.push(Effect::FetchAppBilling);
|
||||
}
|
||||
// Fetch changelog (mirrors startup path for interactive login).
|
||||
effects.push(Effect::FetchChangelog);
|
||||
|
||||
// ZDR-blocked users stay on the welcome screen — discard any
|
||||
// deferred startup (they cannot start a session).
|
||||
if app.is_zdr_blocked() {
|
||||
clear_startup_actions(app);
|
||||
return effects;
|
||||
}
|
||||
|
||||
// Replay deferred session startup once BOTH gates are open. Auth
|
||||
// is now Done, so `session_startup_allowed()` here means "is trust
|
||||
// also resolved?" -- if trust is still Pending its question renders
|
||||
// next and its answer drains instead. Same predicate the trust
|
||||
// handlers use, so the deferred startup runs exactly once after
|
||||
// whichever gate resolves last.
|
||||
if app.session_startup_allowed() {
|
||||
effects.extend(drain_startup_actions(app));
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_auth_url_ready(
|
||||
app: &mut AppView,
|
||||
request_seq: u64,
|
||||
auth_url: Option<String>,
|
||||
external: bool,
|
||||
mode: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
if let AuthState::Authenticating {
|
||||
request_seq: current_seq,
|
||||
auth_url: current_url,
|
||||
mode: current_mode,
|
||||
..
|
||||
} = &mut app.auth_state
|
||||
&& *current_seq == request_seq
|
||||
{
|
||||
*current_url = auth_url;
|
||||
// Prefer `mode`; fall back to `external` for older agents. An
|
||||
// old-agent device login lands on Loopback (harmless paste box;
|
||||
// the background poll still completes).
|
||||
*current_mode = match mode.as_deref() {
|
||||
Some("device") => AuthMode::Device,
|
||||
Some("command") => AuthMode::Command,
|
||||
Some("loopback") => AuthMode::Loopback,
|
||||
_ if external => AuthMode::Command,
|
||||
_ => AuthMode::Loopback,
|
||||
};
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_mcp_auth_trigger_done(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
server_name: String,
|
||||
result: Result<(), String>,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
if let Some(ref mut modal) = agent.extensions_modal {
|
||||
modal.pending_action = None;
|
||||
modal.pending_entry_index = None;
|
||||
if let Err(e) = result {
|
||||
// String-match heuristic: directive vs name-embedded vs generic.
|
||||
// Brittle if the shell ever quotes a name shape that doesn't
|
||||
// match `server_name` here — replace with a structured
|
||||
// discriminator on McpAuthTriggerResponse if that happens.
|
||||
let msg = if e.starts_with("To authenticate") {
|
||||
format!("{server_name}: {e}")
|
||||
} else if e.contains(&server_name) {
|
||||
format!("Auth failed: {e}")
|
||||
} else {
|
||||
format!("{server_name} auth failed: {e}")
|
||||
};
|
||||
modal.modal_message = Some(crate::views::extensions_modal::ModalMessage::Error(msg));
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
// No toast on success: the row transition from the FetchMcpsList
|
||||
// refresh below is the confirmation.
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
vec![Effect::FetchMcpsList {
|
||||
agent_id,
|
||||
session_id,
|
||||
cache: false,
|
||||
}]
|
||||
}
|
||||
Reference in New Issue
Block a user