refactor(cli): split main.rs into per-responsibility modules
main.rs had grown to ~19.8k lines with 8+ unrelated concerns interleaved function-by-function throughout the file (arg parsing, boot/health checks, runtime bootstrap, provider client, progress reporting, model resolution, skill dispatch all mixed together). Split along those natural boundaries into separate files, keeping main.rs as a thin entry point (constants, main(), mod declarations, existing tests). No logic changed — items moved verbatim, marked pub(crate) where a new module boundary required it, with `use crate::*;` in each new file so cross-module references keep resolving through main.rs's re-exports. Verified with cargo check/clippy/test on this crate and scripts/fmt.sh; confirmed the few remaining test failures (provider-detection defaults in the api/rusty-claude-cli crates) are pre-existing on a clean checkout of main, unrelated to this change. New files: - model_provenance.rs — model alias/version resolution - cli_parse.rs — CLI arg parsing, help, report formatting - preflight.rs — doctor/health checks, boot snapshot, git status - bootstrap.rs — runtime bootstrap, LiveCli, session management - provider_client.rs — Anthropic client, tool executor, MCP wiring - progress.rs — prompt/hook progress reporting - skill_dispatch.rs — bare-skill dispatch, cwd guard, diff reports Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
cf963c3071
commit
0089e45724
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,211 @@
|
|||
use crate::*;
|
||||
|
||||
use api::{
|
||||
detect_provider_kind, model_family_identity_for, resolve_startup_auth_source, AnthropicClient,
|
||||
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||
MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient,
|
||||
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||
ToolResultContentBlock,
|
||||
};
|
||||
use commands::{
|
||||
classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json,
|
||||
handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command,
|
||||
handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help,
|
||||
render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands,
|
||||
slash_command_specs, validate_slash_command_input, PluginsCommandResult, SkillSlashDispatch,
|
||||
SlashCommand,
|
||||
};
|
||||
use init::initialize_repo;
|
||||
use log::debug;
|
||||
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||
use runtime::{
|
||||
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
|
||||
load_system_prompt, load_system_prompt_with_context, pricing_for_model, resolve_expected_base,
|
||||
resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState,
|
||||
CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile,
|
||||
ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig,
|
||||
McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
|
||||
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
|
||||
RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, IsTerminal, Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
use tools::{
|
||||
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry,
|
||||
RuntimeToolDefinition, ToolSearchOutput,
|
||||
};
|
||||
|
||||
/// #148: Model provenance for `claw status` JSON/text output. Records where
|
||||
/// the resolved model string came from so claws don't have to re-read argv
|
||||
/// to audit whether their `--model` flag was honored vs falling back to env
|
||||
/// or config or default.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ModelSource {
|
||||
/// Explicit `--model` / `--model=` CLI flag.
|
||||
Flag,
|
||||
/// Runtime model environment variable (when no flag was passed).
|
||||
Env,
|
||||
/// `model` key in `.claw.json` / `.claw/settings.json` (when neither
|
||||
/// flag nor env set it).
|
||||
Config,
|
||||
/// Compiled-in `DEFAULT_MODEL` fallback.
|
||||
Default,
|
||||
}
|
||||
|
||||
impl ModelSource {
|
||||
pub(crate) fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ModelSource::Flag => "flag",
|
||||
ModelSource::Env => "env",
|
||||
ModelSource::Config => "config",
|
||||
ModelSource::Default => "default",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ModelProvenance {
|
||||
/// Resolved model string (after alias expansion).
|
||||
pub(crate) resolved: String,
|
||||
/// Raw user input before alias resolution. None when source is Default.
|
||||
pub(crate) raw: Option<String>,
|
||||
/// Where the resolved model string originated.
|
||||
pub(crate) source: ModelSource,
|
||||
/// Alias-expanded target when `raw` differs from `resolved`.
|
||||
pub(crate) alias_resolved_to: Option<String>,
|
||||
/// Environment variable that supplied the model, when source is Env.
|
||||
pub(crate) env_var: Option<String>,
|
||||
}
|
||||
|
||||
impl ModelProvenance {
|
||||
pub(crate) fn default_fallback() -> Self {
|
||||
Self {
|
||||
resolved: DEFAULT_MODEL.to_string(),
|
||||
raw: None,
|
||||
source: ModelSource::Default,
|
||||
alias_resolved_to: None,
|
||||
env_var: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_flag(raw: &str, resolved: &str) -> Self {
|
||||
Self::from_resolved(raw, resolved, ModelSource::Flag, None)
|
||||
}
|
||||
|
||||
pub(crate) fn from_raw(raw: &str, source: ModelSource, env_var: Option<&str>) -> Self {
|
||||
let resolved = resolve_model_alias_with_config(raw);
|
||||
Self::from_resolved(raw, &resolved, source, env_var)
|
||||
}
|
||||
|
||||
pub(crate) fn from_resolved(
|
||||
raw: &str,
|
||||
resolved: &str,
|
||||
source: ModelSource,
|
||||
env_var: Option<&str>,
|
||||
) -> Self {
|
||||
let raw_trimmed = raw.trim();
|
||||
let alias_resolved_to = (raw_trimmed != resolved).then(|| resolved.to_string());
|
||||
Self {
|
||||
resolved: resolved.to_string(),
|
||||
raw: Some(raw.to_string()),
|
||||
source,
|
||||
alias_resolved_to,
|
||||
env_var: env_var.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_env_or_config_or_default(cli_model: &str) -> Result<Self, String> {
|
||||
// Only called when no --model flag was passed. Probe env first,
|
||||
// then config, else fall back to default. Mirrors the logic in
|
||||
// resolve_repl_model() but captures the source.
|
||||
if cli_model != DEFAULT_MODEL {
|
||||
let provenance = Self::from_resolved(cli_model, cli_model, ModelSource::Flag, None);
|
||||
provenance.validate()?;
|
||||
return Ok(provenance);
|
||||
}
|
||||
if let Some(env_model) = env_model_for_runtime() {
|
||||
let provenance =
|
||||
Self::from_raw(&env_model.value, ModelSource::Env, Some(env_model.name));
|
||||
provenance.validate()?;
|
||||
return Ok(provenance);
|
||||
}
|
||||
if let Some(config_model) = config_model_for_current_dir() {
|
||||
let provenance = Self::from_raw(&config_model, ModelSource::Config, None);
|
||||
provenance.validate()?;
|
||||
return Ok(provenance);
|
||||
}
|
||||
Ok(Self::default_fallback())
|
||||
}
|
||||
|
||||
pub(crate) fn validate(&self) -> Result<(), String> {
|
||||
validate_model_syntax(&self.resolved).map_err(|error| {
|
||||
let source = match self.source {
|
||||
ModelSource::Flag => "--model",
|
||||
ModelSource::Env => self.env_var.as_deref().unwrap_or("environment"),
|
||||
ModelSource::Config => "config model",
|
||||
ModelSource::Default => "default model",
|
||||
};
|
||||
if let Some(raw) = &self.raw {
|
||||
format!(
|
||||
"invalid_model: {source} model `{raw}` is invalid after alias resolution to `{}`.\n{error}",
|
||||
self.resolved
|
||||
)
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_model_alias(model: &str) -> &str {
|
||||
match model {
|
||||
"opus" => "anthropic/claude-opus-4-7",
|
||||
"sonnet" => "anthropic/claude-sonnet-4-6",
|
||||
"haiku" => "anthropic/claude-haiku-4-5-20251213",
|
||||
_ => model,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a model name through user-defined config aliases first, then fall
|
||||
/// back to the built-in alias table. This is the entry point used wherever a
|
||||
/// user-supplied model string is about to be dispatched to a provider.
|
||||
pub(crate) fn resolve_model_alias_with_config(model: &str) -> String {
|
||||
let trimmed = model.trim();
|
||||
if let Some(resolved) = config_alias_for_current_dir(trimmed) {
|
||||
return resolve_model_alias(&resolved).to_string();
|
||||
}
|
||||
resolve_model_alias(trimmed).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn config_alias_for_current_dir(alias: &str) -> Option<String> {
|
||||
if alias.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cwd = env::current_dir().ok()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let config = loader.load().ok()?;
|
||||
config.aliases().get(alias).cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn config_model_for_current_dir() -> Option<String> {
|
||||
let cwd = env::current_dir().ok()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
loader.load().ok()?.model().map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_repl_model(cli_model: String) -> Result<String, String> {
|
||||
Ok(ModelProvenance::from_env_or_config_or_default(&cli_model)?.resolved)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,416 @@
|
|||
use crate::*;
|
||||
|
||||
use api::{
|
||||
detect_provider_kind, model_family_identity_for, resolve_startup_auth_source, AnthropicClient,
|
||||
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||
MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient,
|
||||
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||
ToolResultContentBlock,
|
||||
};
|
||||
use commands::{
|
||||
classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json,
|
||||
handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command,
|
||||
handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help,
|
||||
render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands,
|
||||
slash_command_specs, validate_slash_command_input, PluginsCommandResult, SkillSlashDispatch,
|
||||
SlashCommand,
|
||||
};
|
||||
use init::initialize_repo;
|
||||
use log::debug;
|
||||
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||
use runtime::{
|
||||
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
|
||||
load_system_prompt, load_system_prompt_with_context, pricing_for_model, resolve_expected_base,
|
||||
resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState,
|
||||
CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile,
|
||||
ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig,
|
||||
McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
|
||||
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
|
||||
RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, IsTerminal, Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
use tools::{
|
||||
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry,
|
||||
RuntimeToolDefinition, ToolSearchOutput,
|
||||
};
|
||||
|
||||
pub(crate) struct HookAbortMonitor {
|
||||
pub(crate) stop_tx: Option<Sender<()>>,
|
||||
pub(crate) join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl HookAbortMonitor {
|
||||
pub(crate) fn spawn(abort_signal: runtime::HookAbortSignal) -> Self {
|
||||
Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| {
|
||||
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
runtime.block_on(async move {
|
||||
let wait_for_stop = tokio::task::spawn_blocking(move || {
|
||||
let _ = stop_rx.recv();
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
result = tokio::signal::ctrl_c() => {
|
||||
if result.is_ok() {
|
||||
abort_signal.abort();
|
||||
}
|
||||
}
|
||||
_ = wait_for_stop => {}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_with_waiter<F>(
|
||||
abort_signal: runtime::HookAbortSignal,
|
||||
wait_for_interrupt: F,
|
||||
) -> Self
|
||||
where
|
||||
F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static,
|
||||
{
|
||||
let (stop_tx, stop_rx) = mpsc::channel();
|
||||
let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal));
|
||||
|
||||
Self {
|
||||
stop_tx: Some(stop_tx),
|
||||
join_handle: Some(join_handle),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stop(mut self) {
|
||||
if let Some(stop_tx) = self.stop_tx.take() {
|
||||
let _ = stop_tx.send(());
|
||||
}
|
||||
if let Some(join_handle) = self.join_handle.take() {
|
||||
let _ = join_handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct InternalPromptProgressState {
|
||||
pub(crate) command_label: &'static str,
|
||||
pub(crate) task_label: String,
|
||||
pub(crate) step: usize,
|
||||
pub(crate) phase: String,
|
||||
pub(crate) detail: Option<String>,
|
||||
pub(crate) saw_final_text: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum InternalPromptProgressEvent {
|
||||
Started,
|
||||
Update,
|
||||
Heartbeat,
|
||||
Complete,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InternalPromptProgressShared {
|
||||
pub(crate) state: Mutex<InternalPromptProgressState>,
|
||||
pub(crate) output_lock: Mutex<()>,
|
||||
pub(crate) started_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct InternalPromptProgressReporter {
|
||||
pub(crate) shared: Arc<InternalPromptProgressShared>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InternalPromptProgressRun {
|
||||
pub(crate) reporter: InternalPromptProgressReporter,
|
||||
pub(crate) heartbeat_stop: Option<mpsc::Sender<()>>,
|
||||
pub(crate) heartbeat_handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl InternalPromptProgressReporter {
|
||||
pub(crate) fn ultraplan(task: &str) -> Self {
|
||||
Self {
|
||||
shared: Arc::new(InternalPromptProgressShared {
|
||||
state: Mutex::new(InternalPromptProgressState {
|
||||
command_label: "Ultraplan",
|
||||
task_label: task.to_string(),
|
||||
step: 0,
|
||||
phase: "planning started".to_string(),
|
||||
detail: Some(format!("task: {task}")),
|
||||
saw_final_text: false,
|
||||
}),
|
||||
output_lock: Mutex::new(()),
|
||||
started_at: Instant::now(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) {
|
||||
let snapshot = self.snapshot();
|
||||
let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error);
|
||||
self.write_line(&line);
|
||||
}
|
||||
|
||||
pub(crate) fn mark_model_phase(&self) {
|
||||
let snapshot = {
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.expect("internal prompt progress state poisoned");
|
||||
state.step += 1;
|
||||
state.phase = if state.step == 1 {
|
||||
"analyzing request".to_string()
|
||||
} else {
|
||||
"reviewing findings".to_string()
|
||||
};
|
||||
state.detail = Some(format!("task: {}", state.task_label));
|
||||
state.clone()
|
||||
};
|
||||
self.write_line(&format_internal_prompt_progress_line(
|
||||
InternalPromptProgressEvent::Update,
|
||||
&snapshot,
|
||||
self.elapsed(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn mark_tool_phase(&self, name: &str, input: &str) {
|
||||
let detail = describe_tool_progress(name, input);
|
||||
let snapshot = {
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.expect("internal prompt progress state poisoned");
|
||||
state.step += 1;
|
||||
state.phase = format!("running {name}");
|
||||
state.detail = Some(detail);
|
||||
state.clone()
|
||||
};
|
||||
self.write_line(&format_internal_prompt_progress_line(
|
||||
InternalPromptProgressEvent::Update,
|
||||
&snapshot,
|
||||
self.elapsed(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn mark_text_phase(&self, text: &str) {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let detail = truncate_for_summary(first_visible_line(trimmed), 120);
|
||||
let snapshot = {
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.expect("internal prompt progress state poisoned");
|
||||
if state.saw_final_text {
|
||||
return;
|
||||
}
|
||||
state.saw_final_text = true;
|
||||
state.step += 1;
|
||||
state.phase = "drafting final plan".to_string();
|
||||
state.detail = (!detail.is_empty()).then_some(detail);
|
||||
state.clone()
|
||||
};
|
||||
self.write_line(&format_internal_prompt_progress_line(
|
||||
InternalPromptProgressEvent::Update,
|
||||
&snapshot,
|
||||
self.elapsed(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn emit_heartbeat(&self) {
|
||||
let snapshot = self.snapshot();
|
||||
self.write_line(&format_internal_prompt_progress_line(
|
||||
InternalPromptProgressEvent::Heartbeat,
|
||||
&snapshot,
|
||||
self.elapsed(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> InternalPromptProgressState {
|
||||
self.shared
|
||||
.state
|
||||
.lock()
|
||||
.expect("internal prompt progress state poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn elapsed(&self) -> Duration {
|
||||
self.shared.started_at.elapsed()
|
||||
}
|
||||
|
||||
pub(crate) fn write_line(&self, line: &str) {
|
||||
let _guard = self
|
||||
.shared
|
||||
.output_lock
|
||||
.lock()
|
||||
.expect("internal prompt progress output lock poisoned");
|
||||
let mut stdout = io::stdout();
|
||||
let _ = writeln!(stdout, "{line}");
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
|
||||
impl InternalPromptProgressRun {
|
||||
pub(crate) fn start_ultraplan(task: &str) -> Self {
|
||||
let reporter = InternalPromptProgressReporter::ultraplan(task);
|
||||
reporter.emit(InternalPromptProgressEvent::Started, None);
|
||||
|
||||
let (heartbeat_stop, heartbeat_rx) = mpsc::channel();
|
||||
let heartbeat_reporter = reporter.clone();
|
||||
let heartbeat_handle = thread::spawn(move || loop {
|
||||
match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) {
|
||||
Ok(()) | Err(RecvTimeoutError::Disconnected) => break,
|
||||
Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(),
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
reporter,
|
||||
heartbeat_stop: Some(heartbeat_stop),
|
||||
heartbeat_handle: Some(heartbeat_handle),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reporter(&self) -> InternalPromptProgressReporter {
|
||||
self.reporter.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn finish_success(&mut self) {
|
||||
self.stop_heartbeat();
|
||||
self.reporter
|
||||
.emit(InternalPromptProgressEvent::Complete, None);
|
||||
}
|
||||
|
||||
pub(crate) fn finish_failure(&mut self, error: &str) {
|
||||
self.stop_heartbeat();
|
||||
self.reporter
|
||||
.emit(InternalPromptProgressEvent::Failed, Some(error));
|
||||
}
|
||||
|
||||
pub(crate) fn stop_heartbeat(&mut self) {
|
||||
if let Some(sender) = self.heartbeat_stop.take() {
|
||||
let _ = sender.send(());
|
||||
}
|
||||
if let Some(handle) = self.heartbeat_handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InternalPromptProgressRun {
|
||||
fn drop(&mut self) {
|
||||
self.stop_heartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_internal_prompt_progress_line(
|
||||
event: InternalPromptProgressEvent,
|
||||
snapshot: &InternalPromptProgressState,
|
||||
elapsed: Duration,
|
||||
error: Option<&str>,
|
||||
) -> String {
|
||||
let elapsed_seconds = elapsed.as_secs();
|
||||
let step_label = if snapshot.step == 0 {
|
||||
"current step pending".to_string()
|
||||
} else {
|
||||
format!("current step {}", snapshot.step)
|
||||
};
|
||||
let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)];
|
||||
if let Some(detail) = snapshot
|
||||
.detail
|
||||
.as_deref()
|
||||
.filter(|detail| !detail.is_empty())
|
||||
{
|
||||
status_bits.push(detail.to_string());
|
||||
}
|
||||
let status = status_bits.join(" · ");
|
||||
match event {
|
||||
InternalPromptProgressEvent::Started => {
|
||||
format!(
|
||||
"🧭 {} status · planning started · {status}",
|
||||
snapshot.command_label
|
||||
)
|
||||
}
|
||||
InternalPromptProgressEvent::Update => {
|
||||
format!("… {} status · {status}", snapshot.command_label)
|
||||
}
|
||||
InternalPromptProgressEvent::Heartbeat => format!(
|
||||
"… {} heartbeat · {elapsed_seconds}s elapsed · {status}",
|
||||
snapshot.command_label
|
||||
),
|
||||
InternalPromptProgressEvent::Complete => format!(
|
||||
"✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total",
|
||||
snapshot.command_label, snapshot.step
|
||||
),
|
||||
InternalPromptProgressEvent::Failed => format!(
|
||||
"✘ {} status · failed · {elapsed_seconds}s elapsed · {}",
|
||||
snapshot.command_label,
|
||||
error.unwrap_or("unknown error")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct CliHookProgressReporter;
|
||||
|
||||
impl runtime::HookProgressReporter for CliHookProgressReporter {
|
||||
fn on_event(&mut self, event: &runtime::HookProgressEvent) {
|
||||
match event {
|
||||
runtime::HookProgressEvent::Started {
|
||||
event,
|
||||
tool_name,
|
||||
command,
|
||||
} => eprintln!(
|
||||
"[hook {event_name}] {tool_name}: {command}",
|
||||
event_name = event.as_str()
|
||||
),
|
||||
runtime::HookProgressEvent::Completed {
|
||||
event,
|
||||
tool_name,
|
||||
command,
|
||||
} => eprintln!(
|
||||
"[hook done {event_name}] {tool_name}: {command}",
|
||||
event_name = event.as_str()
|
||||
),
|
||||
runtime::HookProgressEvent::Cancelled {
|
||||
event,
|
||||
tool_name,
|
||||
command,
|
||||
} => eprintln!(
|
||||
"[hook cancelled {event_name}] {tool_name}: {command}",
|
||||
event_name = event.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn first_visible_line(text: &str) -> &str {
|
||||
text.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.unwrap_or(text)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,105 @@
|
|||
use crate::*;
|
||||
|
||||
use api::{
|
||||
detect_provider_kind, model_family_identity_for, resolve_startup_auth_source, AnthropicClient,
|
||||
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||
MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient,
|
||||
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||
ToolResultContentBlock,
|
||||
};
|
||||
use commands::{
|
||||
classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json,
|
||||
handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command,
|
||||
handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help,
|
||||
render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands,
|
||||
slash_command_specs, validate_slash_command_input, PluginsCommandResult, SkillSlashDispatch,
|
||||
SlashCommand,
|
||||
};
|
||||
use init::initialize_repo;
|
||||
use log::debug;
|
||||
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||
use runtime::{
|
||||
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
|
||||
load_system_prompt, load_system_prompt_with_context, pricing_for_model, resolve_expected_base,
|
||||
resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState,
|
||||
CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile,
|
||||
ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig,
|
||||
McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
|
||||
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
|
||||
RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, IsTerminal, Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
use tools::{
|
||||
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry,
|
||||
RuntimeToolDefinition, ToolSearchOutput,
|
||||
};
|
||||
|
||||
pub(crate) fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option<String> {
|
||||
let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default();
|
||||
let looks_like_skill_name = !bare_first_token.is_empty()
|
||||
&& !bare_first_token.starts_with('/')
|
||||
&& bare_first_token
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_');
|
||||
if !looks_like_skill_name {
|
||||
return None;
|
||||
}
|
||||
match resolve_skill_invocation(cwd, Some(trimmed)) {
|
||||
Ok(SkillSlashDispatch::Invoke(prompt)) => Some(prompt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Verify we are inside a git repository before calling `git diff`.
|
||||
// Running `git diff --cached` outside a git tree produces a misleading
|
||||
// "unknown option `cached`" error because git falls back to --no-index mode.
|
||||
let in_git_repo = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--is-inside-work-tree"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.is_ok_and(|o| o.status.success());
|
||||
if !in_git_repo {
|
||||
return Ok(format!(
|
||||
"Diff\n Result no git repository\n Detail {} is not inside a git project",
|
||||
cwd.display()
|
||||
));
|
||||
}
|
||||
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
|
||||
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
|
||||
if staged.trim().is_empty() && unstaged.trim().is_empty() {
|
||||
return Ok(
|
||||
"Diff\n Result clean working tree\n Detail no current changes"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut sections = Vec::new();
|
||||
if !staged.trim().is_empty() {
|
||||
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
|
||||
}
|
||||
if !unstaged.trim().is_empty() {
|
||||
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
|
||||
}
|
||||
|
||||
Ok(format!("Diff\n\n{}", sections.join("\n\n")))
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub(crate) fn resolve_cli_auth_source_for_cwd() -> Result<AuthSource, api::ApiError> {
|
||||
resolve_startup_auth_source(|| Ok(None))
|
||||
}
|
||||
Loading…
Reference in New Issue