mod init;
mod input;
mod render;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::io::{self, Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use dialoguer::Select;
use console::style;
use api::{
TernlangClient, AuthSource, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, OutputContentBlock,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use commands::{render_slash_command_help, slash_command_specs, SlashCommand};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
use render::{Spinner, TerminalRenderer};
use runtime::{
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
PermissionMode, PermissionPolicy,
ProjectContext, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
};
use serde_json::json;
use tools::{execute_tool, mvp_tool_specs, ToolSpec};
const DEFAULT_MODEL: &str = "gemini-2.0-flash";
fn max_tokens_for_model(model: &str) -> u32 {
if model.contains("haiku") {
16_000
} else {
32_000
}
}
const DEFAULT_DATE: &str = "2024-03-25";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
type AllowedToolSet = BTreeSet<String>;
fn main() {
if let Err(error) = run() {
eprintln!(
"error: {error}
Run `claw --help` for usage."
);
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect();
match parse_args(&args)? {
CliAction::DumpManifests => dump_manifests(),
CliAction::BootstrapPlan => print_bootstrap_plan(),
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
CliAction::Version => print_version(),
CliAction::ResumeSession {
session_path,
commands,
} => resume_session(&session_path, &commands),
CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
} => LiveCli::new(model, true, allowed_tools, permission_mode)?
.run_turn_with_output(&prompt, output_format),
CliAction::Login => run_login(),
CliAction::Logout => run_logout(),
CliAction::Init => run_init(),
CliAction::Repl {
model,
allowed_tools,
permission_mode,
} => {
let mut config_path = dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"));
config_path.push("albert/config.toml");
if !config_path.exists() {
init::wake_sequence();
}
run_repl(model, allowed_tools, permission_mode)
},
CliAction::Help => {
println!("{}", render_repl_help());
Ok(())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction {
DumpManifests,
BootstrapPlan,
PrintSystemPrompt {
cwd: PathBuf,
date: String,
},
Version,
ResumeSession {
session_path: PathBuf,
commands: Vec<String>,
},
Prompt {
prompt: String,
model: String,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
},
Login,
Logout,
Init,
Repl {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
},
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CliOutputFormat {
Text,
Json,
}
impl CliOutputFormat {
fn parse(value: &str) -> Result<Self, String> {
match value {
"text" => Ok(Self::Text),
"json" => Ok(Self::Json),
other => Err(format!(
"unsupported value for --output-format: {other} (expected text or json)"
)),
}
}
}
#[allow(clippy::too_many_lines)]
fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut model = DEFAULT_MODEL.to_string();
let mut output_format = CliOutputFormat::Text;
let mut permission_mode = default_permission_mode();
let mut wants_version = false;
let mut allowed_tool_values = Vec::new();
let mut rest = Vec::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--version" | "-V" => {
wants_version = true;
index += 1;
}
"--model" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?;
model = resolve_model_alias(value).to_string();
index += 2;
}
flag if flag.starts_with("--model=") => {
model = resolve_model_alias(&flag[8..]).to_string();
index += 1;
}
"--output-format" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --output-format".to_string())?;
output_format = CliOutputFormat::parse(value)?;
index += 2;
}
"--permission-mode" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --permission-mode".to_string())?;
permission_mode = parse_permission_mode_arg(value)?;
index += 2;
}
flag if flag.starts_with("--output-format=") => {
output_format = CliOutputFormat::parse(&flag[16..])?;
index += 1;
}
flag if flag.starts_with("--permission-mode=") => {
permission_mode = parse_permission_mode_arg(&flag[18..])?;
index += 1;
}
"--dangerously-skip-permissions" => {
permission_mode = PermissionMode::DangerFullAccess;
index += 1;
}
"-p" => {
let prompt = args[index + 1..].join(" ");
if prompt.trim().is_empty() {
return Err("-p requires a prompt string".to_string());
}
return Ok(CliAction::Prompt {
prompt,
model: resolve_model_alias(&model).to_string(),
output_format,
allowed_tools: normalize_allowed_tools(&allowed_tool_values)?,
permission_mode,
});
}
"--print" => {
output_format = CliOutputFormat::Text;
index += 1;
}
"--allowedTools" | "--allowed-tools" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --allowedTools".to_string())?;
allowed_tool_values.push(value.clone());
index += 2;
}
flag if flag.starts_with("--allowedTools=") => {
allowed_tool_values.push(flag[15..].to_string());
index += 1;
}
flag if flag.starts_with("--allowed-tools=") => {
allowed_tool_values.push(flag[16..].to_string());
index += 1;
}
other => {
rest.push(other.to_string());
index += 1;
}
}
}
if wants_version {
return Ok(CliAction::Version);
}
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
if rest.is_empty() {
return Ok(CliAction::Repl {
model,
allowed_tools,
permission_mode,
});
}
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
return Ok(CliAction::Help);
}
if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..]);
}
match rest[0].as_str() {
"dump-manifests" => Ok(CliAction::DumpManifests),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
"system-prompt" => parse_system_prompt_args(&rest[1..]),
"login" => Ok(CliAction::Login),
"logout" => Ok(CliAction::Logout),
"init" => Ok(CliAction::Init),
"prompt" => {
let prompt = rest[1..].join(" ");
if prompt.trim().is_empty() {
return Err("prompt subcommand requires a prompt string".to_string());
}
Ok(CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
})
}
other if !other.starts_with('/') => Ok(CliAction::Prompt {
prompt: rest.join(" "),
model,
output_format,
allowed_tools,
permission_mode,
}),
other => Err(format!("unknown subcommand: {other}")),
}
}
fn resolve_model_alias(model: &str) -> &str {
match model {
"opus" => "claude-3-opus-20240229",
"sonnet" => "claude-3-sonnet-20240229",
"haiku" => "claude-3-haiku-20240307",
"flash" => "gemini-2.0-flash",
"pro" => "gemini-1.5-pro",
_ => model,
}
}
fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
if values.is_empty() {
return Ok(None);
}
let canonical_names = mvp_tool_specs()
.into_iter()
.map(|spec| spec.name.to_string())
.collect::<Vec<_>>();
let mut name_map = canonical_names
.iter()
.map(|name| (normalize_tool_name(name), name.clone()))
.collect::<BTreeMap<_, _>>();
for (alias, canonical) in [
("read", "read_file"),
("write", "write_file"),
("edit", "edit_file"),
("glob", "glob_search"),
("grep", "grep_search"),
] {
name_map.insert(alias.to_string(), canonical.to_string());
}
let mut allowed = AllowedToolSet::new();
for value in values {
for token in value
.split(|ch: char| ch == ',' || ch.is_whitespace())
.filter(|token| !token.is_empty())
{
let normalized = normalize_tool_name(token);
let canonical = name_map.get(&normalized).ok_or_else(|| {
format!(
"unsupported tool in --allowedTools: {token} (expected one of: {})",
canonical_names.join(", ")
)
})?;
allowed.insert(canonical.clone());
}
}
Ok(Some(allowed))
}
fn normalize_tool_name(value: &str) -> String {
value.trim().replace('-', "_").to_ascii_lowercase()
}
fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
normalize_permission_mode(value)
.ok_or_else(|| {
format!(
"unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
)
})
.map(permission_mode_from_label)
}
fn permission_mode_from_label(mode: &str) -> PermissionMode {
match mode {
"read-only" => PermissionMode::ReadOnly,
"workspace-write" => PermissionMode::WorkspaceWrite,
"danger-full-access" => PermissionMode::DangerFullAccess,
other => panic!("unsupported permission mode label: {other}"),
}
}
fn default_permission_mode() -> PermissionMode {
env::var("RUSTY_TERNLANG_PERMISSION_MODE")
.ok()
.as_deref()
.and_then(normalize_permission_mode)
.map_or(PermissionMode::DangerFullAccess, permission_mode_from_label)
}
fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec<tools::ToolSpec> {
mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.collect()
}
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
let mut date = DEFAULT_DATE.to_string();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--cwd" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --cwd".to_string())?;
cwd = PathBuf::from(value);
index += 2;
}
"--date" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --date".to_string())?;
date.clone_from(value);
index += 2;
}
other => return Err(format!("unknown system-prompt option: {other}")),
}
}
Ok(CliAction::PrintSystemPrompt { cwd, date })
}
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
let session_path = args
.first()
.ok_or_else(|| "missing session path for --resume".to_string())
.map(PathBuf::from)?;
let commands = args[1..].to_vec();
if commands
.iter()
.any(|command| !command.trim_start().starts_with('/'))
{
return Err("--resume trailing arguments must be slash commands".to_string());
}
Ok(CliAction::ResumeSession {
session_path,
commands,
})
}
fn dump_manifests() -> Result<(), Box<dyn std::error::Error>> {
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
let manifest = extract_manifest(&paths)?;
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
Ok(())
}
fn print_bootstrap_plan() -> Result<(), Box<dyn std::error::Error>> {
for phase in runtime::BootstrapPlan::ternlang_cli_default().phases() {
println!("- {phase:?}");
}
Ok(())
}
fn default_oauth_config() -> OAuthConfig {
OAuthConfig {
client_id: String::from("9d1c250a-e61b-44d9-88ed-5944d1962f5e"),
authorize_url: String::from("https://console.anthropic.com/oauth/authorize"),
token_url: String::from("https://api.anthropic.com/v1/oauth/token"),
callback_port: None,
manual_redirect_url: None,
scopes: vec![
String::from("user:profile"),
String::from("user:inference"),
String::from("user:sessions:ternlang_cli"),
],
}
}
fn run_login() -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let config = ConfigLoader::default_for(&cwd).load()?;
let default_oauth = default_oauth_config();
let oauth = config.oauth().unwrap_or(&default_oauth);
let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT);
let redirect_uri = runtime::loopback_redirect_uri(callback_port);
let pkce = generate_pkce_pair()?;
let state = generate_state()?;
let authorize_url =
OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
.build_url();
println!("Starting Anthropic OAuth login...");
println!("Listening for callback on {redirect_uri}");
if let Err(error) = open_browser(&authorize_url) {
eprintln!("warning: failed to open browser automatically: {error}");
println!("Open this URL manually:
{authorize_url}");
}
let callback = wait_for_oauth_callback(callback_port)?;
if let Some(error) = callback.error {
let description = callback
.error_description
.unwrap_or_else(|| "authorization failed".to_string());
return Err(io::Error::other(format!("{error}: {description}")).into());
}
let code = callback.code.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "callback did not include code")
})?;
let returned_state = callback.state.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "callback did not include state")
})?;
if returned_state != state {
return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into());
}
let client = TernlangClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
let exchange_request = api::OAuthTokenExchangeRequest {
code,
redirect_uri,
};
let runtime = tokio::runtime::Runtime::new()?;
let token_set = runtime.block_on(client.exchange_oauth_code(api::OAuthConfig {}, &exchange_request))?;
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: token_set.access_token,
refresh_token: token_set.refresh_token,
expires_at: token_set.expires_at,
scopes: token_set.scopes,
})?;
println!("Anthropic OAuth login complete.");
Ok(())
}
fn run_logout() -> Result<(), Box<dyn std::error::Error>> {
clear_oauth_credentials()?;
println!("Anthropic OAuth credentials cleared.");
Ok(())
}
fn open_browser(url: &str) -> io::Result<()> {
let commands = if cfg!(target_os = "macos") {
vec![("open", vec![url])]
} else if cfg!(target_os = "windows") {
vec![("cmd", vec!["/C", "start", "", url])]
} else {
vec![("xdg-open", vec![url])]
};
for (program, args) in commands {
match Command::new(program).args(args).spawn() {
Ok(_) => return Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
"no supported browser opener command found",
))
}
fn wait_for_oauth_callback(
port: u16,
) -> Result<runtime::OAuthCallbackParams, Box<dyn std::error::Error>> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
let (mut stream, _) = listener.accept()?;
let mut buffer = [0_u8; 4096];
let bytes_read = stream.read(&mut buffer)?;
let request = String::from_utf8_lossy(&buffer[..bytes_read]);
let request_line = request.lines().next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "missing callback request line")
})?;
let target = request_line.split_whitespace().nth(1).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"missing callback request target",
)
})?;
let callback = parse_oauth_callback_request_target(target)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let body = if callback.error.is_some() {
"Anthropic OAuth login failed. You can close this window."
} else {
"Anthropic OAuth login succeeded. You can close this window."
};
let response = format!(
"HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: {}
connection: close
{}",
body.len(),
body
);
stream.write_all(response.as_bytes())?;
Ok(callback)
}
fn print_system_prompt(cwd: PathBuf, date: String) -> Result<(), Box<dyn std::error::Error>> {
let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?;
println!("{}", sections.join("
"));
Ok(())
}
fn print_version() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_version_report());
Ok(())
}
fn resume_session(session_path: &Path, commands: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let mut session = Session::load_from_path(session_path)?;
if commands.is_empty() {
println!(
"Restored session from {} ({} messages).",
session_path.display(),
session.messages.len()
);
return Ok(());
}
for raw_command in commands {
let Some(command) = SlashCommand::parse(raw_command) else {
eprintln!("unsupported resumed command: {raw_command}");
std::process::exit(2);
};
let outcome = run_resume_command(session_path, &session, &command)?;
session = outcome.session;
if let Some(message) = outcome.message {
println!("{message}");
}
}
Ok(())
}
#[derive(Debug, Clone)]
struct ResumeCommandOutcome {
session: Session,
message: Option<String>,
}
#[derive(Debug, Clone)]
struct StatusContext {
cwd: PathBuf,
session_path: Option<PathBuf>,
loaded_config_files: usize,
discovered_config_files: usize,
memory_file_count: usize,
project_root: Option<PathBuf>,
git_branch: Option<String>,
}
#[derive(Debug, Clone, Copy)]
struct StatusUsage {
message_count: usize,
turns: u32,
latest: TokenUsage,
cumulative: TokenUsage,
estimated_tokens: usize,
}
fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
format!(
"Model
Current model {model}
Session messages {message_count}
Session turns {turns}
Usage
Inspect current model with /model
Switch models with /model <name>"
)
}
fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
format!(
"Model updated
Previous {previous}
Current {next}
Preserved msgs {message_count}"
)
}
fn format_permissions_report(mode: &str) -> String {
let modes = [
("read-only", "Read/search tools only", mode == "read-only"),
(
"workspace-write",
"Edit files inside the workspace",
mode == "workspace-write",
),
(
"danger-full-access",
"Unrestricted tool access",
mode == "danger-full-access",
),
]
.into_iter()
.map(|(name, description, is_current)| {
let marker = if is_current {
"● current"
} else {
"○ available"
};
format!(" {name:<18} {marker:<11} {description}")
})
.collect::<Vec<_>>()
.join(
"
",
);
format!(
"Permissions
Active mode {mode}
Mode status live session default
Modes
{modes}
Usage
Inspect current mode with /permissions
Switch modes with /permissions <mode>"
)
}
fn format_permissions_switch_report(previous: &str, next: &str) -> String {
format!(
"Permissions updated
Result mode switched
Previous mode {previous}
Active mode {next}
Applies to subsequent tool calls
Usage /permissions to inspect current mode"
)
}
fn format_cost_report(usage: TokenUsage) -> String {
format!(
"Cost
Input tokens {}
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
usage.total_tokens(),
)
}
fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
format!(
"Session resumed
Session file {session_path}
Messages {message_count}
Turns {turns}"
)
}
fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
if skipped {
format!(
"Compact
Result skipped
Reason session below compaction threshold
Messages kept {resulting_messages}"
)
} else {
format!(
"Compact
Result compacted
Messages removed {removed}
Messages kept {resulting_messages}"
)
}
}
fn format_auto_compaction_notice(removed: usize) -> String {
format!("[auto-compacted: removed {removed} messages]")
}
fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
let Some(status) = status else {
return (None, None);
};
let branch = status.lines().next().and_then(|line| {
line.strip_prefix("## ")
.map(|line| {
line.split(['.', ' '])
.next()
.unwrap_or_default()
.to_string()
})
.filter(|value| !value.is_empty())
});
let project_root = find_git_root().ok();
(project_root, branch)
}
fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
return Err("not a git repository".into());
}
let path = String::from_utf8(output.stdout)?.trim().to_string();
if path.is_empty() {
return Err("empty git root".into());
}
Ok(PathBuf::from(path))
}
#[allow(clippy::too_many_lines)]
fn run_resume_command(
session_path: &Path,
session: &Session,
command: &SlashCommand,
) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
match command {
SlashCommand::Help => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_repl_help()),
}),
SlashCommand::Compact => {
let result = runtime::compact_session(
session,
CompactionConfig {
max_estimated_tokens: 0,
..CompactionConfig::default()
},
);
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
result.compacted_session.save_to_path(session_path)?;
Ok(ResumeCommandOutcome {
session: result.compacted_session,
message: Some(format_compact_report(removed, kept, skipped)),
})
}
SlashCommand::Clear { confirm } => {
if !confirm {
return Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(
"clear: confirmation required; rerun with /clear --confirm".to_string(),
),
});
}
let cleared = Session::new();
cleared.save_to_path(session_path)?;
Ok(ResumeCommandOutcome {
session: cleared,
message: Some(format!(
"Cleared resumed session file {}.",
session_path.display()
)),
})
}
SlashCommand::Status => {
let tracker = UsageTracker::from_session(session);
let usage = tracker.cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_status_report(
"restored-session",
StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
latest: tracker.current_turn_usage(),
cumulative: usage,
estimated_tokens: 0,
},
default_permission_mode().as_str(),
&status_context(Some(session_path))?,
)),
})
}
SlashCommand::Cost => {
let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
})
}
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_config_report(section.as_deref())?),
}),
SlashCommand::Memory => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_memory_report()?),
}),
SlashCommand::Init => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(init_ternlang_md()?),
}),
SlashCommand::Diff => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_diff_report()?),
}),
SlashCommand::Version => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_version_report()),
}),
SlashCommand::Export { path } => {
let export_path = resolve_export_path(path.as_deref(), session)?;
fs::write(&export_path, render_export_text(session))?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format!(
"Export
Result wrote transcript
File {}
Messages {}",
export_path.display(),
session.messages.len(),
)),
})
}
SlashCommand::Auth { .. }
| SlashCommand::Bughunter { .. }
| SlashCommand::Commit
| SlashCommand::Pr { .. }
| SlashCommand::Issue { .. }
| SlashCommand::Ultraplan { .. }
| SlashCommand::Teleport { .. }
| SlashCommand::DebugToolCall
| SlashCommand::Resume { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plan { .. }
| SlashCommand::Tdd { .. }
| SlashCommand::Verify
| SlashCommand::CodeReview { .. }
| SlashCommand::BuildFix
| SlashCommand::Aside { .. }
| SlashCommand::Learn
| SlashCommand::Refactor { .. }
| SlashCommand::Checkpoint { .. }
| SlashCommand::Docs { .. }
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Compress
| SlashCommand::Loop { .. }
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
}
}
fn run_repl(
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<(), Box<dyn std::error::Error>> {
check_workspace_trust()?;
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
let mut editor = input::LineEditor::new("> ", slash_command_completion_candidates());
println!("{}", cli.startup_banner());
loop {
match editor.read_line()? {
input::ReadOutcome::Submit(input) => {
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
continue;
}
if matches!(trimmed.as_str(), "/exit" | "/quit") {
cli.persist_session()?;
break;
}
if let Some(command) = SlashCommand::parse(&trimmed) {
if cli.handle_repl_command(command)? {
cli.persist_session()?;
}
continue;
}
editor.push_history(input);
cli.run_turn(&trimmed)?;
}
input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => {
cli.persist_session()?;
break;
}
}
}
Ok(())
}
#[derive(Debug, Clone)]
struct SessionHandle {
id: String,
path: PathBuf,
}
#[derive(Debug, Clone)]
struct ManagedSessionSummary {
id: String,
path: PathBuf,
modified_epoch_secs: u64,
message_count: usize,
}
struct LiveCli {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
system_prompt: Vec<String>,
runtime: ConversationRuntime<TernlangRuntimeClient, CliToolExecutor>,
session: SessionHandle,
}
impl LiveCli {
fn new(
model: String,
enable_tools: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?;
let runtime = build_runtime(
Session::new(),
model.clone(),
system_prompt.clone(),
enable_tools,
true,
allowed_tools.clone(),
permission_mode,
)?;
let cli = Self {
model,
allowed_tools,
permission_mode,
system_prompt,
runtime,
session,
};
cli.persist_session()?;
Ok(cli)
}
fn startup_banner(&self) -> String {
let cwd = env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
);
format!(
"
█████╗ ██╗ ██████╗ ███████╗██████╗ ████████╗
██╔══██╗ ██║ ██╔══██╗██╔════╝██╔══██╗╚══██╔══╝
███████║ ██║ ██████╔╝█████╗ ██████╔╝ ██║
██╔══██║ ██║ ██╔══██╗██╔══╝ ██╔══██╗ ██║
██║ ██║ ███████╗██████╔╝███████╗██║ ██║ ██║
╚═╝ ╚═╝ ╚══════╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
Model {}
Permissions {}
Directory {}
Session {}
Type /help for commands · Shift+Enter for newline",
self.model,
self.permission_mode.as_str(),
cwd,
self.session.id,
)
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
spinner.tick(
"🦀 Thinking...",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode, true);
let result = self
.runtime
.run_turn(input.to_string(), Some(&mut permission_prompter));
match result {
Ok(summary) => {
spinner.finish(
"✨ Done",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
println!();
if let Some(event) = summary.auto_compaction {
println!(
"{}",
format_auto_compaction_notice(event.removed_message_count)
);
}
self.persist_session()?;
Ok(())
}
Err(error) => {
spinner.fail(
"❌ Request failed",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
Err(Box::new(error))
}
}
}
fn run_turn_with_output(
&mut self,
input: &str,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Text => self.run_turn(input),
CliOutputFormat::Json => self.run_prompt_json(input),
}
}
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let session = self.runtime.session().clone();
let mut runtime = build_runtime(
session,
self.model.clone(),
self.system_prompt.clone(),
true,
false,
self.allowed_tools.clone(),
self.permission_mode,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode, false);
let summary = runtime.run_turn(input.to_string(), Some(&mut permission_prompter))?;
self.runtime = runtime;
self.persist_session()?;
println!(
"{}",
json!({
"message": final_assistant_text(&summary),
"model": self.model,
"iterations": summary.iterations,
"auto_compaction": summary.auto_compaction.map(|event| json!({
"removed_messages": event.removed_message_count,
"notice": format_auto_compaction_notice(event.removed_message_count),
})),
"tool_uses": collect_tool_uses(&summary),
"tool_results": collect_tool_results(&summary),
"usage": {
"input_tokens": summary.usage.input_tokens,
"output_tokens": summary.usage.output_tokens,
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
}
})
);
Ok(())
}
fn handle_repl_command(
&mut self,
command: SlashCommand,
) -> Result<bool, Box<dyn std::error::Error>> {
Ok(match command {
SlashCommand::Help => {
println!("{}", render_repl_help());
false
}
SlashCommand::Status => {
self.print_status()?;
false
}
SlashCommand::Bughunter { scope } => {
self.run_bughunter(scope.as_deref())?;
false
}
SlashCommand::Commit => {
self.run_commit()?;
true
}
SlashCommand::Pr { context } => {
self.run_pr(context.as_deref())?;
false
}
SlashCommand::Issue { context } => {
self.run_issue(context.as_deref())?;
false
}
SlashCommand::Ultraplan { task } => {
self.run_ultraplan(task.as_deref())?;
false
}
SlashCommand::Teleport { target } => {
self.run_teleport(target.as_deref())?;
false
}
SlashCommand::DebugToolCall => {
self.run_debug_tool_call()?;
false
}
SlashCommand::Compact => {
self.compact()?;
false
}
SlashCommand::Compress => {
self.compress()?;
false
}
SlashCommand::Model { model } => self.set_model(model)?,
SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
SlashCommand::Cost => {
self.print_cost();
false
}
SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
SlashCommand::Config { section } => {
Self::print_config(section.as_deref())?;
false
}
SlashCommand::Memory => {
Self::print_memory()?;
false
}
SlashCommand::Init => {
run_init()?;
false
}
SlashCommand::Diff => {
Self::print_diff()?;
false
}
SlashCommand::Version => {
Self::print_version()?;
false
}
SlashCommand::Export { path } => {
self.export_session(path.as_deref())?;
false
}
SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())?
}
SlashCommand::Auth { provider } => {
self.run_auth(provider.as_deref())?;
false
}
SlashCommand::Plan { task } => {
println!("Plan initiated: {}. I am restating requirements and assessing risks...", task.unwrap_or_else(|| "current task".to_string()));
false
}
SlashCommand::Tdd { interface } => {
println!("TDD loop engaged for {}. Scaffold -> Failing Test -> Implement.", interface.unwrap_or_else(|| "target".to_string()));
false
}
SlashCommand::Verify => {
println!("Running full verification: build, lint, test, and type-check...");
false
}
SlashCommand::CodeReview { files } => {
println!("Reviewing {}. Assessing quality, security, and maintainability.", files.unwrap_or_else(|| "changed files".to_string()));
false
}
SlashCommand::BuildFix => {
println!("Detecting build errors. Dispatching resolver agents...");
false
}
SlashCommand::Aside { question } => {
println!("Pivot: {}. Answering side question without losing context.", question.unwrap_or_else(|| "...".to_string()));
false
}
SlashCommand::Learn => {
println!("Extracting reusable patterns and learned instincts...");
false
}
SlashCommand::Refactor { scope } => {
println!("Refactoring {}. Removing dead code and consolidating duplicates.", scope.unwrap_or_else(|| "workspace".to_string()));
false
}
SlashCommand::Checkpoint { label } => {
println!("Checkpoint marked: {}.", label.unwrap_or_else(|| "manual".to_string()));
false
}
SlashCommand::Docs { query } => {
println!("Looking up docs for {}. Querying Context7...", query.unwrap_or_else(|| "project".to_string()));
false
}
SlashCommand::Loop { mission } => {
self.run_loop(mission.as_deref())?;
false
}
SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}");
false
}
})
}
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.session().save_to_path(&self.session.path)?;
Ok(())
}
fn print_status(&self) -> Result<(), Box<dyn std::error::Error>> {
let cumulative = self.runtime.usage().cumulative_usage();
let latest = self.runtime.usage().current_turn_usage();
println!(
"{}",
format_status_report(
&self.model,
StatusUsage {
message_count: self.runtime.session().messages.len(),
turns: self.runtime.usage().turns(),
latest,
cumulative,
estimated_tokens: self.runtime.estimated_tokens(),
},
self.permission_mode.as_str(),
&status_context(Some(&self.session.path))?,
)
);
Ok(())
}
fn set_model(&mut self, model: Option<String>) -> Result<bool, Box<dyn std::error::Error>> {
let Some(model) = model else {
println!(
"{}",
format_model_report(
&self.model,
self.runtime.session().messages.len(),
self.runtime.usage().turns(),
)
);
return Ok(false);
};
let model = resolve_model_alias(&model).to_string();
if model == self.model {
println!(
"{}",
format_model_report(
&self.model,
self.runtime.session().messages.len(),
self.runtime.usage().turns(),
)
);
return Ok(false);
}
let previous = self.model.clone();
let session = self.runtime.session().clone();
let message_count = session.messages.len();
self.runtime = build_runtime(
session,
model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.model.clone_from(&model);
println!(
"{}",
format_model_switch_report(&previous, &model, message_count)
);
Ok(true)
}
fn set_permissions(
&mut self,
mode: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(mode) = mode else {
println!(
"{}",
format_permissions_report(self.permission_mode.as_str())
);
return Ok(false);
};
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
format!(
"unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
)
})?;
if normalized == self.permission_mode.as_str() {
println!("{}", format_permissions_report(normalized));
return Ok(false);
}
let previous = self.permission_mode.as_str().to_string();
let session = self.runtime.session().clone();
self.permission_mode = permission_mode_from_label(normalized);
self.runtime = build_runtime(
session,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
println!(
"{}",
format_permissions_switch_report(&previous, normalized)
);
Ok(true)
}
fn clear_session(&mut self, confirm: bool) -> Result<bool, Box<dyn std::error::Error>> {
if !confirm {
println!(
"clear: confirmation required; run /clear --confirm to start a fresh session."
);
return Ok(false);
}
self.session = create_managed_session_handle()?;
self.runtime = build_runtime(
Session::new(),
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
println!(
"Session cleared
Mode fresh session
Preserved model {}
Permission mode {}
Session {}",
self.model,
self.permission_mode.as_str(),
self.session.id,
);
Ok(true)
}
fn print_cost(&self) {
let cumulative = self.runtime.usage().cumulative_usage();
println!("{}", format_cost_report(cumulative));
}
fn resume_session(
&mut self,
session_path: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else {
println!("Usage: /resume <session-path>");
return Ok(false);
};
let handle = resolve_session_reference(&session_ref)?;
let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len();
self.runtime = build_runtime(
session,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.session = handle;
println!(
"{}",
format_resume_report(
&self.session.path.display().to_string(),
message_count,
self.runtime.usage().turns(),
)
);
Ok(true)
}
fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_config_report(section)?);
Ok(())
}
fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_memory_report()?);
Ok(())
}
fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_diff_report()?);
Ok(())
}
fn print_version() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_version_report());
Ok(())
}
fn export_session(
&self,
requested_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let export_path = resolve_export_path(requested_path, self.runtime.session())?;
fs::write(&export_path, render_export_text(self.runtime.session()))?;
println!(
"Export
Result wrote transcript
File {}
Messages {}",
export_path.display(),
self.runtime.session().messages.len(),
);
Ok(())
}
fn handle_session_command(
&mut self,
action: Option<&str>,
target: Option<&str>,
) -> Result<bool, Box<dyn std::error::Error>> {
match action {
None | Some("list") => {
println!("{}", render_session_list(&self.session.id)?);
Ok(false)
}
Some("switch") => {
let Some(target) = target else {
println!("Usage: /session switch <session-id>");
return Ok(false);
};
let handle = resolve_session_reference(target)?;
let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len();
self.runtime = build_runtime(
session,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.session = handle;
println!(
"Session switched
Active session {}
File {}
Messages {}",
self.session.id,
self.session.path.display(),
message_count,
);
Ok(true)
}
Some(other) => {
println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>.");
Ok(false)
}
}
}
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let result = self.runtime.compact(CompactionConfig::default());
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
self.runtime = build_runtime(
result.compacted_session,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.persist_session()?;
println!("{}", format_compact_report(removed, kept, skipped));
Ok(())
}
fn compress(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let result = self.runtime.compact(CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1, });
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
self.runtime = build_runtime(
result.compacted_session,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.persist_session()?;
if skipped {
println!("Compression skipped: session is empty or too short.");
} else {
println!(
"Aggressively compressed {} messages. Albert's memory is now lean and sharp ({} kept).",
removed, kept
);
}
Ok(())
}
fn run_auth(&mut self, provider_name: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let provider = match provider_name {
Some(name) => name.to_lowercase(),
None => {
println!("Available providers: openai, anthropic, huggingface, google, azure, aws");
print!("Choose provider: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
input.trim().to_lowercase()
}
};
print!("Enter API Key for {provider}: ");
io::stdout().flush()?;
let mut api_key = String::new();
io::stdin().read_line(&mut api_key)?;
let api_key = api_key.trim().to_string();
if api_key.is_empty() {
println!("Error: API Key cannot be empty.");
return Ok(());
}
runtime::save_provider_config(&provider, runtime::ProviderConfig {
api_key: Some(api_key),
model: None,
base_url: None,
})?;
println!("Authentication configured for {provider}.");
Ok(())
}
fn run_internal_prompt_text(
&self,
prompt: &str,
enable_tools: bool,
) -> Result<String, Box<dyn std::error::Error>> {
let session = self.runtime.session().clone();
let mut runtime = build_runtime(
session,
self.model.clone(),
self.system_prompt.clone(),
enable_tools,
false,
self.allowed_tools.clone(),
self.permission_mode,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode, false);
let summary = runtime.run_turn(prompt.to_string(), Some(&mut permission_prompter))?;
Ok(final_assistant_text(&summary).trim().to_string())
}
fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let scope = scope.unwrap_or("the current repository");
let prompt = format!(
"You are /bughunter. Inspect {scope} and identify the most likely bugs or correctness issues. Prioritize concrete findings with file paths, severity, and suggested fixes. Use tools if needed."
);
println!("{}", self.run_internal_prompt_text(&prompt, true)?);
Ok(())
}
fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let task = task.unwrap_or("the current repo work");
let prompt = format!(
"You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed."
);
println!("{}", self.run_internal_prompt_text(&prompt, true)?);
Ok(())
}
fn run_teleport(&self, target: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
println!("Usage: /teleport <symbol-or-path>");
return Ok(());
};
println!("{}", render_teleport_report(target)?);
Ok(())
}
fn run_debug_tool_call(&self) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_last_tool_debug_report(self.runtime.session())?);
Ok(())
}
fn run_commit(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let status = git_output(&["status", "--short"])?;
if status.trim().is_empty() {
println!("Commit
Result skipped
Reason no workspace changes");
return Ok(());
}
git_status_ok(&["add", "-A"])?;
let staged_stat = git_output(&["diff", "--cached", "--stat"])?;
let prompt = format!(
"Generate a git commit message in plain text Lore format only. Base it on this staged diff summary:
{}
Recent conversation context:
{}",
truncate_for_prompt(&staged_stat, 8_000),
recent_user_context(self.runtime.session(), 6)
);
let message = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
if message.trim().is_empty() {
return Err("generated commit message was empty".into());
}
let path = write_temp_text_file("claw-commit-message.txt", &message)?;
let output = Command::new("git")
.args(["commit", "--file"])
.arg(&path)
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git commit failed: {stderr}").into());
}
println!(
"Commit
Result created
Message file {}
{}",
path.display(),
message.trim()
);
Ok(())
}
fn run_pr(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let staged = git_output(&["diff", "--stat"])?;
let prompt = format!(
"Generate a pull request title and body from this conversation and diff summary. Output plain text in this format exactly:
TITLE: <title>
BODY:
<body markdown>
Context hint: {}
Diff summary:
{}",
context.unwrap_or("none"),
truncate_for_prompt(&staged, 10_000)
);
let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
let (title, body) = parse_titled_body(&draft)
.ok_or_else(|| "failed to parse generated PR title/body".to_string())?;
if command_exists("gh") {
let body_path = write_temp_text_file("claw-pr-body.md", &body)?;
let output = Command::new("gh")
.args(["pr", "create", "--title", &title, "--body-file"])
.arg(&body_path)
.current_dir(env::current_dir()?)
.output()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!(
"PR
Result created
Title {title}
URL {}",
if stdout.is_empty() { "<unknown>" } else { &stdout }
);
return Ok(());
}
}
println!("PR draft
Title {title}
{body}");
Ok(())
}
fn run_issue(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let prompt = format!(
"Generate a GitHub issue title and body from this conversation. Output plain text in this format exactly:
TITLE: <title>
BODY:
<body markdown>
Context hint: {}
Conversation context:
{}",
context.unwrap_or("none"),
truncate_for_prompt(&recent_user_context(self.runtime.session(), 10), 10_000)
);
let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
let (title, body) = parse_titled_body(&draft)
.ok_or_else(|| "failed to parse generated issue title/body".to_string())?;
if command_exists("gh") {
let body_path = write_temp_text_file("claw-issue-body.md", &body)?;
let output = Command::new("gh")
.args(["issue", "create", "--title", &title, "--body-file"])
.arg(&body_path)
.current_dir(env::current_dir()?)
.output()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!(
"Issue
Result created
Title {title}
URL {}",
if stdout.is_empty() { "<unknown>" } else { &stdout }
);
return Ok(());
}
}
println!("Issue draft
Title {title}
{body}");
Ok(())
}
fn run_loop(&mut self, mission: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let mission_text = mission.unwrap_or("Complete the current objective.").to_string();
println!("\n{} {}", style("🚀 MISSION STARTED:").bold().green(), style(&mission_text).cyan());
let mut turn_count = 0;
let max_turns = 10;
loop {
turn_count += 1;
if turn_count > max_turns {
println!("\n{} Maximum turn limit reached ({}). Pausing autopilot for alignment.", style("⚠️").yellow(), max_turns);
break;
}
println!("\n{} [Iteration {}/{}]", style("🌀 Autopilot").bold().magenta(), turn_count, max_turns);
let loop_prompt = format!(
"MISSION: {}\n\nContinue executing the mission. If the mission is fully complete, tested, and validated, end your response with 'MISSION COMPLETE'. Otherwise, continue with the next logical step. Spawn internal swarm reasoning if needed.",
mission_text
);
self.run_turn(&loop_prompt)?;
let last_message = self.runtime.session().messages.last();
if let Some(msg) = last_message {
let content = msg.blocks.iter().filter_map(|b| if let ContentBlock::Text { text } = b { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join(" ");
if content.contains("MISSION COMPLETE") {
println!("\n{} Mission objectives achieved. Harness complete.", style("✨ MISSION SUCCESSFUL").bold().green());
break;
}
}
thread::sleep(Duration::from_millis(500));
}
Ok(())
}
}
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let path = cwd.join(".claw").join("sessions");
fs::create_dir_all(&path)?;
Ok(path)
}
fn create_managed_session_handle() -> Result<SessionHandle, Box<dyn std::error::Error>> {
let id = generate_session_id();
let path = sessions_dir()?.join(format!("{id}.json"));
Ok(SessionHandle { id, path })
}
fn generate_session_id() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
format!("session-{millis}")
}
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let direct = PathBuf::from(reference);
let path = if direct.exists() {
direct
} else {
sessions_dir()?.join(format!("{reference}.json"))
};
if !path.exists() {
return Err(format!("session not found: {reference}").into());
}
let id = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or(reference)
.to_string();
Ok(SessionHandle { id, path })
}
fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
let mut sessions = Vec::new();
for entry in fs::read_dir(sessions_dir()?)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let metadata = entry.metadata()?;
let modified_epoch_secs = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or_default();
let message_count = Session::load_from_path(&path)
.map(|session| session.messages.len())
.unwrap_or_default();
let id = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string();
sessions.push(ManagedSessionSummary {
id,
path,
modified_epoch_secs,
message_count,
});
}
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
Ok(sessions)
}
fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
let sessions = list_managed_sessions()?;
let mut lines = vec![
"Sessions".to_string(),
format!(" Directory {}", sessions_dir()?.display()),
];
if sessions.is_empty() {
lines.push(" No managed sessions saved yet.".to_string());
return Ok(lines.join("
"));
}
for session in sessions {
let marker = if session.id == active_session_id {
"● current"
} else {
"○ saved"
};
lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
id = session.id,
msgs = session.message_count,
modified = session.modified_epoch_secs,
path = session.path.display(),
));
}
Ok(lines.join("
"))
}
fn render_repl_help() -> String {
[
"REPL".to_string(),
" /exit Quit the REPL".to_string(),
" /quit Quit the REPL".to_string(),
" Up/Down Navigate prompt history".to_string(),
" Tab Complete slash commands".to_string(),
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
String::new(),
render_slash_command_help(),
]
.join(
"
",
)
}
fn status_context(
session_path: Option<&Path>,
) -> Result<StatusContext, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered_config_files = loader.discover().len();
let runtime_config = loader.load()?;
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) =
parse_git_status_metadata(project_context.git_status.as_deref());
Ok(StatusContext {
cwd,
session_path: session_path.map(Path::to_path_buf),
loaded_config_files: runtime_config.loaded_entries().len(),
discovered_config_files,
memory_file_count: project_context.instruction_files.len(),
project_root,
git_branch,
})
}
fn format_status_report(
model: &str,
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
) -> String {
[
format!(
"Status
Model {model}
Permission mode {permission_mode}
Messages {}
Turns {}
Estimated tokens {}",
usage.message_count, usage.turns, usage.estimated_tokens,
),
format!(
"Usage
Latest total {}
Cumulative input {}
Cumulative output {}
Cumulative total {}",
usage.latest.total_tokens(),
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.total_tokens(),
),
format!(
"Workspace
Cwd {}
Project root {}
Git branch {}
Session {}
Config files loaded {}/{}
Memory files {}",
context.cwd.display(),
context
.project_root
.as_ref()
.map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
context.git_branch.as_deref().unwrap_or("unknown"),
context.session_path.as_ref().map_or_else(
|| "live-repl".to_string(),
|path| path.display().to_string()
),
context.loaded_config_files,
context.discovered_config_files,
context.memory_file_count,
),
]
.join(
"
",
)
}
fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover();
let runtime_config = loader.load()?;
let mut lines = vec![
format!(
"Config
Working directory {}
Loaded files {}
Merged keys {}",
cwd.display(),
runtime_config.loaded_entries().len(),
runtime_config.merged().len()
),
"Discovered files".to_string(),
];
for entry in discovered {
let source = match entry.source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let status = if runtime_config
.loaded_entries()
.iter()
.any(|loaded_entry| loaded_entry.path == entry.path)
{
"loaded"
} else {
"missing"
};
lines.push(format!(
" {source:<7} {status:<7} {}",
entry.path.display()
));
}
if let Some(section) = section {
lines.push(format!("Merged section: {section}"));
let value = match section {
"env" => runtime_config.get("env"),
"hooks" => runtime_config.get("hooks"),
"model" => runtime_config.get("model"),
other => {
lines.push(format!(
" Unsupported config section '{other}'. Use env, hooks, or model."
));
return Ok(lines.join(
"
",
));
}
};
lines.push(format!(
" {}",
match value {
Some(value) => value.render(),
None => "<unset>".to_string(),
}
));
return Ok(lines.join(
"
",
));
}
lines.push("Merged JSON".to_string());
lines.push(format!(" {}", runtime_config.as_json().render()));
Ok(lines.join(
"
",
))
}
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
let mut lines = vec![format!(
"Memory
Working directory {}
Instruction files {}",
cwd.display(),
project_context.instruction_files.len()
)];
if project_context.instruction_files.is_empty() {
lines.push("Discovered files".to_string());
lines.push(
" No TERNLANG instruction files discovered in the current directory ancestry."
.to_string(),
);
} else {
lines.push("Discovered files".to_string());
for (index, file) in project_context.instruction_files.iter().enumerate() {
let preview = file.content.lines().next().unwrap_or("").trim();
let preview = if preview.is_empty() {
"<empty>"
} else {
preview
};
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
lines.push(format!(
" lines={} preview={}",
file.content.lines().count(),
preview
));
}
}
Ok(lines.join(
"
",
))
}
fn init_ternlang_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
Ok(initialize_repo(&cwd)?.render())
}
fn run_init() -> Result<(), Box<dyn std::error::Error>> {
init::wake_sequence();
println!("\n{}", init_ternlang_md()?);
Ok(())
}
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
match mode.trim() {
"read-only" => Some("read-only"),
"workspace-write" => Some("workspace-write"),
"danger-full-access" => Some("danger-full-access"),
_ => None,
}
}
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["diff", "--", ":(exclude).omx"])
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git diff failed: {stderr}").into());
}
let diff = String::from_utf8(output.stdout)?;
if diff.trim().is_empty() {
return Ok(
"Diff
Result clean working tree
Detail no current changes"
.to_string(),
);
}
Ok(format!("Diff
{}", diff.trim_end()))
}
fn render_version_report() -> String {
format!(
"Ternlang Code CLI version {}
target: {}
git sha: {}",
VERSION,
BUILD_TARGET.unwrap_or("unknown"),
GIT_SHA.unwrap_or("unknown")
)
}
struct CliPermissionPrompter {
permission_mode: PermissionMode,
interactive: bool,
}
impl CliPermissionPrompter {
fn new(permission_mode: PermissionMode, interactive: bool) -> Self {
Self {
permission_mode,
interactive,
}
}
}
impl runtime::PermissionPrompter for CliPermissionPrompter {
fn decide(
&mut self,
request: &runtime::PermissionRequest,
) -> runtime::PermissionPromptDecision {
let default = match self.permission_mode {
PermissionMode::ReadOnly => runtime::PermissionPromptDecision::Deny {
reason: "read-only mode".to_string(),
},
PermissionMode::WorkspaceWrite => {
if request.tool_name.starts_with("shell") {
runtime::PermissionPromptDecision::Deny {
reason: "shell tools disabled in workspace-write mode".to_string(),
}
} else {
runtime::PermissionPromptDecision::Allow
}
}
PermissionMode::DangerFullAccess => runtime::PermissionPromptDecision::Allow,
PermissionMode::Prompt => runtime::PermissionPromptDecision::Allow,
PermissionMode::Allow => runtime::PermissionPromptDecision::Allow,
};
if !self.interactive || !matches!(default, runtime::PermissionPromptDecision::Allow) {
return default;
}
let tool_input_string = request.input.to_string();
if tool_input_string.len() > 256 {
println!(
"Request to use tool `{}` with large input. Preview:\n{}...",
request.tool_name,
&tool_input_string[..256]
);
} else {
println!("Request to use tool `{}` with input:\n{}", request.tool_name, request.input);
}
loop {
let choice = dialoguer::Select::new()
.with_prompt("Allow this tool use?")
.items(&["Allow once", "Deny"])
.default(0)
.interact_opt()
.unwrap_or_default();
match choice {
Some(0) => return runtime::PermissionPromptDecision::Allow,
Some(1) => {
return runtime::PermissionPromptDecision::Deny {
reason: "user denied".to_string(),
}
}
Some(_) => {
return runtime::PermissionPromptDecision::Deny {
reason: "invalid choice".to_string(),
}
}
None => {
return runtime::PermissionPromptDecision::Deny {
reason: "user cancelled".to_string(),
};
}
}
}
}
}
fn resolve_provider_for_model(model: &str) -> api::LlmProvider {
if model.contains("gpt-") || model.contains("o1-") || model.contains("o3-") {
api::LlmProvider::OpenAi
} else if model.contains("claude-") || model.contains("opus") || model.contains("sonnet") || model.contains("haiku") {
api::LlmProvider::Anthropic
} else if model.contains("gemini-") {
api::LlmProvider::Google
} else if model.contains("llama") || model.contains("mistral") {
api::LlmProvider::HuggingFace
} else {
api::LlmProvider::Ternlang
}
}
fn build_runtime(
session: Session,
model: String,
system_prompt: Vec<String>,
enable_tools: bool,
enable_stream_events: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<ConversationRuntime<TernlangRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
let cwd = env::current_dir()?;
let _config = ConfigLoader::default_for(&cwd).load()?;
let provider = resolve_provider_for_model(&model);
let provider_config = runtime::load_provider_config(match provider {
api::LlmProvider::OpenAi => "openai",
api::LlmProvider::Anthropic => "anthropic",
api::LlmProvider::Google => "google",
api::LlmProvider::HuggingFace => "huggingface",
_ => "ternlang",
}).unwrap_or(None);
let auth_source = if let Some(config) = provider_config {
if let Some(key) = config.api_key {
api::AuthSource::ApiKey(key)
} else {
api::AuthSource::None
}
} else {
api::resolve_startup_auth_source().unwrap_or(api::AuthSource::None)
};
let client = TernlangClient::from_auth(auth_source).with_provider(provider);
let api_client = TernlangRuntimeClient {
client,
model: model.clone(),
max_tokens: max_tokens_for_model(&model),
tools: if enable_tools {
filter_tool_specs(allowed_tools.as_ref())
} else {
Vec::new()
},
event_tx: if enable_stream_events {
Some(
tokio::runtime::Runtime::new()?
.block_on(async {
let (tx, _) = tokio::sync::broadcast::channel(128);
tx
}),
)
} else {
None
},
};
let tool_executor = CliToolExecutor::new();
let permission_policy = PermissionPolicy::new(permission_mode);
Ok(ConversationRuntime::new(
session,
api_client,
tool_executor,
permission_policy,
system_prompt,
))
}
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
Ok(load_system_prompt(
cwd,
DEFAULT_DATE.to_string(),
env::consts::OS,
"unknown",
)?)
}
#[derive(Clone)]
struct TernlangRuntimeClient {
client: TernlangClient,
model: String,
max_tokens: u32,
tools: Vec<ToolSpec>,
event_tx: Option<tokio::sync::broadcast::Sender<AssistantEvent>>,
}
impl ApiClient for TernlangRuntimeClient {
fn stream(
&mut self,
request: ApiRequest,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| RuntimeError::new(e.to_string()))?;
runtime.block_on(self.stream_async(request)).map_err(|e| RuntimeError::new(e.to_string()))
}
}
impl TernlangRuntimeClient {
async fn stream_async(
&mut self,
request: ApiRequest,
) -> Result<Vec<AssistantEvent>, Box<dyn std::error::Error + Send + Sync>> {
let mut stream = self
.client
.stream_message(&MessageRequest {
model: self.model.clone(),
max_tokens: Some(self.max_tokens),
messages: request
.messages
.into_iter()
.map(map_conversation_message)
.collect(),
system: Some(request.system_prompt.join("
")),
tools: if self.tools.is_empty() {
None
} else {
Some(self.tools.iter().map(map_tool_spec).collect())
},
tool_choice: if self.tools.is_empty() {
None
} else {
Some(ToolChoice::Auto)
},
stream: true,
})
.await?;
let mut events = Vec::new();
while let Some(event) = stream.next_event().await? {
if let Some(mapped) = map_stream_event(&event) {
if let Some(tx) = &self.event_tx {
let _ = tx.send(mapped.clone());
}
events.push(mapped);
}
}
Ok(events)
}
}
fn map_conversation_message(message: ConversationMessage) -> InputMessage {
InputMessage {
role: match message.role {
MessageRole::User => "user".to_string(),
MessageRole::Assistant => "assistant".to_string(),
MessageRole::Tool => "user".to_string(),
MessageRole::System => "system".to_string(),
},
content: message
.blocks
.into_iter()
.map(map_content_block)
.collect(),
}
}
fn map_content_block(block: ContentBlock) -> InputContentBlock {
match block {
ContentBlock::Text { text } => InputContentBlock::Text { text },
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { id, name, input: serde_json::Value::String(input) },
ContentBlock::ToolResult { tool_use_id, output, tool_name: _, is_error: _ } => InputContentBlock::ToolResult {
tool_use_id,
content: vec![ToolResultContentBlock::Text { text: output }],
is_error: false,
},
}
}
fn map_tool_spec(spec: &ToolSpec) -> ToolDefinition {
ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema.clone(),
}
}
fn map_stream_event(event: &ApiStreamEvent) -> Option<AssistantEvent> {
match event {
ApiStreamEvent::MessageStart(payload) => Some(AssistantEvent::Usage(map_usage(payload.message.usage.clone()))),
ApiStreamEvent::ContentBlockDelta(payload) => match &payload.delta {
ContentBlockDelta::TextDelta { text } => Some(AssistantEvent::TextDelta(text.clone())),
_ => None,
},
ApiStreamEvent::ContentBlockStart(payload) => match &payload.content_block {
OutputContentBlock::ToolUse { id, name, input } => Some(AssistantEvent::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.to_string(),
}),
_ => None,
},
ApiStreamEvent::MessageDelta(payload) => {
Some(AssistantEvent::Usage(map_usage(payload.usage.clone())))
}
ApiStreamEvent::MessageStop(_) => Some(AssistantEvent::MessageStop),
_ => None,
}
}
fn map_usage(usage: api::Usage) -> TokenUsage {
TokenUsage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_creation_input_tokens: usage.cache_creation_input_tokens,
cache_read_input_tokens: usage.cache_read_input_tokens,
}
}
#[derive(Clone)]
struct CliToolExecutor {}
impl CliToolExecutor {
fn new() -> Self {
Self {}
}
}
impl ToolExecutor for CliToolExecutor {
fn execute(
&mut self,
tool_name: &str,
input: &str,
) -> Result<runtime::ToolResult, ToolError> {
let input_val: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::Value::Null);
match execute_tool(tool_name, &input_val) {
Ok(res) => Ok(runtime::ToolResult {
output: res.output,
state: res.state,
}),
Err(e) => Err(ToolError::new(e)),
}
}
fn query_memory(&mut self, query: &str) -> Result<String, ToolError> {
let input = json!({
"action": "search_nodes",
"query": query
});
match execute_tool("Memory", &input) {
Ok(res) => Ok(res.output),
Err(e) => Err(ToolError::new(e)),
}
}
}
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
summary
.assistant_messages
.iter()
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("
")
}
fn collect_tool_uses(summary: &runtime::TurnSummary) -> serde_json::Value {
serde_json::Value::Array(
summary
.assistant_messages
.iter()
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => Some(json!({
"id": id,
"name": name,
"input": input,
})),
_ => None,
})
.collect(),
)
}
fn collect_tool_results(summary: &runtime::TurnSummary) -> serde_json::Value {
serde_json::Value::Array(
summary
.tool_results
.iter()
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::ToolResult { tool_use_id, output, tool_name: _, is_error: _ } => Some(json!({
"tool_use_id": tool_use_id,
"output": output,
})),
_ => None,
})
.collect(),
)
}
fn slash_command_completion_candidates() -> Vec<String> {
let mut candidates: Vec<String> = slash_command_specs()
.iter()
.map(|spec| format!("/{}", spec.name))
.collect();
candidates.sort();
candidates
}
fn truncate_for_prompt(value: &str, max_chars: usize) -> String {
if value.len() <= max_chars {
return value.to_string();
}
let mut truncated = String::with_capacity(max_chars + 20);
truncated.push_str(&value[..max_chars]);
truncated.push_str("
... (truncated)");
truncated
}
fn write_temp_text_file(name: &str, content: &str) -> Result<PathBuf, io::Error> {
let path = env::temp_dir().join(name);
fs::write(&path, content)?;
Ok(path)
}
fn command_exists(name: &str) -> bool {
let Ok(paths) = env::var("PATH") else {
return false;
};
paths
.split(':')
.map(|path| Path::new(path).join(name))
.any(|path| path.exists())
}
fn sanitize_generated_message(message: &str) -> String {
let message = message.trim();
if let Some(stripped) = message.strip_prefix("```text
") {
return stripped.strip_suffix("```").unwrap_or(stripped).to_string();
}
if let Some(stripped) = message.strip_prefix("```") {
return stripped.strip_suffix("```").unwrap_or(stripped).to_string();
}
message.to_string()
}
fn parse_titled_body(raw: &str) -> Option<(String, String)> {
let Some(title_start) = raw.find("TITLE:") else {
return None;
};
let Some(body_start) = raw.find("BODY:") else {
return None;
};
let title = raw[title_start + 6..body_start].trim().to_string();
let body = raw[body_start + 5..].trim().to_string();
Some((title, body))
}
fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(env::current_dir()?)
.output()?;
Ok(String::from_utf8(output.stdout)?)
}
fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
let status = std::process::Command::new("git")
.args(args)
.current_dir(env::current_dir()?)
.status()?;
if !status.success() {
return Err("git command failed".into());
}
Ok(())
}
fn recent_user_context(session: &Session, max_messages: usize) -> String {
session
.messages
.iter()
.filter(|message| message.role == MessageRole::User)
.rev()
.take(max_messages)
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("
")
}
fn resolve_export_path(requested_path: Option<&str>, _session: &Session) -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(PathBuf::from(requested_path.unwrap_or("export.txt")))
}
fn render_export_text(_session: &Session) -> String {
"".to_string()
}
fn render_teleport_report(_target: &str) -> Result<String, Box<dyn std::error::Error>> {
Ok("".to_string())
}
fn render_last_tool_debug_report(_session: &Session) -> Result<String, Box<dyn std::error::Error>> {
Ok("".to_string())
}
fn check_workspace_trust() -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
println!("\n{}", style("────────────────────────────────────────────────────────────").dim());
println!("Accessing workspace:");
println!("\n {}\n", style(cwd.display()).cyan());
println!("Quick safety check: Is this a project you created or one you trust?");
println!("(Like your own code, a well-known open source project, or work from your team).");
println!("If not, take a moment to review what's in this folder first.\n");
let options = vec!["Yes, I trust this folder", "No, exit"];
let selection = Select::new()
.with_prompt("Security guide")
.items(&options)
.default(0)
.interact()?;
if selection == 1 {
println!("\nExiting for safety.");
std::process::exit(0);
}
println!("{}", style("Trust verified. Let's build.").dim());
Ok(())
}