Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/ironclaw_reborn_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ layer = "app"
dist = false

[features]
default = ["root-llm-provider"]
default = ["root-llm-provider", "webui-v2-beta"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== crates/ironclaw_reborn_cli/Cargo.toml ==\n'
nl -ba crates/ironclaw_reborn_cli/Cargo.toml | sed -n '1,120p'

printf '\n== search for webui-v2-beta usage ==\n'
rg -n --hidden --glob '!target' --glob '!**/node_modules/**' 'webui-v2-beta|root-llm-provider|serve' crates/ironclaw_reborn_cli .claude AGENTS.md CLAUDE.md 2>/dev/null || true

Repository: nearai/ironclaw

Length of output: 238


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== list likely repo policy files ==\n'
fd -a 'CLAUDE.md|AGENTS.md|\.claude' . 2>/dev/null || true

printf '\n== read top-level policy files if present ==\n'
for f in CLAUDE.md AGENTS.md .claude/rules .claude/rules.md .claude/CLAUDE.md; do
  if [ -f "$f" ]; then
    printf '\n--- %s ---\n' "$f"
    nl -ba "$f" | sed -n '1,240p'
  fi
done

Repository: nearai/ironclaw

Length of output: 7040


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Cargo.toml around feature defaults =='
sed -n '1,80p' crates/ironclaw_reborn_cli/Cargo.toml | nl -ba

echo
echo '== all references in CLI crate =='
rg -n 'webui-v2-beta|root-llm-provider|serve' crates/ironclaw_reborn_cli

Repository: nearai/ironclaw

Length of output: 234


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== crates/ironclaw_reborn_cli/Cargo.toml =='
sed -n '1,140p' crates/ironclaw_reborn_cli/Cargo.toml | awk '{printf "%4d  %s\n", NR, $0}'

echo
echo '== root policy files =='
for f in CLAUDE.md AGENTS.md crates/ironclaw_reborn_cli/AGENTS.md crates/ironclaw_webui_v2/CLAUDE.md crates/ironclaw_webui_v2/AGENTS.md; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    sed -n '1,220p' "$f" | awk '{printf "%4d  %s\n", NR, $0}'
    echo
  fi
done

Repository: nearai/ironclaw

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== feature references in CLI crate =='
grep -RIn --exclude-dir=target --exclude-dir=node_modules -E 'webui-v2-beta|root-llm-provider|serve' crates/ironclaw_reborn_cli || true

echo
echo '== likely gating in workspace =='
grep -RIn --exclude-dir=target --exclude-dir=node_modules -E 'webui-v2-beta|webui.*beta|WebChat v2|HTTP gateway|axum|SSE|WS' crates | head -n 200 || true

Repository: nearai/ironclaw

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== crate-specific policy =='
if [ -f crates/ironclaw_reborn_cli/AGENTS.md ]; then
  sed -n '1,220p' crates/ironclaw_reborn_cli/AGENTS.md | awk '{printf "%4d  %s\n", NR, $0}'
fi

echo
echo '== workspace policy mentions of webui-v2-beta =='
grep -RIn --exclude-dir=target --exclude-dir=node_modules -E 'webui-v2-beta must be off by default|must be off by default|explicitly gate the WebChat v2 HTTP gateway' . || true

Repository: nearai/ironclaw

Length of output: 4596


Remove webui-v2-beta from default
crates/ironclaw_reborn_cli/AGENTS.md:35-58 says this feature is off by default and keeps serve out of default builds; crates/ironclaw_reborn_cli/Cargo.toml:22 flips it on, pulling the axum/auth/HTTP/SSE/WS stack into every default binary. Drop it from default unless that crate contract changes in the same PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_cli/Cargo.toml` at line 22, Update the default feature
list in the CLI crate’s Cargo configuration to remove webui-v2-beta, leaving
root-llm-provider enabled. Preserve webui-v2-beta as an opt-in feature so
default builds do not include the web UI stack.

Source: Coding guidelines

# Wire a real LLM provider into the assembled runtime. Mirrors the same
# `root-llm-provider` feature on `ironclaw_reborn_composition` /
# `ironclaw_runner`, so building without it produces a CLI binary that
Expand Down
12 changes: 7 additions & 5 deletions crates/ironclaw_reborn_cli/src/commands/config/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,15 @@ regex_activation_enabled = true
# # session-pool cap after reserving capacity for restarts/operator sessions.
# pool_max_size = 2

[llm.default]
# LLM slot selection. `provider_id` references an entry in
# [llm.default]
# LLM slot selection. Left commented so first run leads to model setup
# (REPL prompt / Web UI welcome screen). Uncomment and edit to pin a
# provider here instead. `provider_id` references an entry in
# providers.json (built-in or user-overlay). `model` / `base_url` /
# `api_key_env` override the catalog defaults for this deployment.
provider_id = "openai"
model = "gpt-4o-mini"
api_key_env = "OPENAI_API_KEY"
# provider_id = "openai"
# model = "gpt-4o-mini"
# api_key_env = "OPENAI_API_KEY"

# [llm.mission]
# # Reserved for the future planned-driver "mission" slot.
Expand Down
111 changes: 107 additions & 4 deletions crates/ironclaw_reborn_cli/src/commands/onboard.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};

use clap::Args;
Expand Down Expand Up @@ -26,6 +27,27 @@ pub(crate) struct OnboardCommand {
/// step explicit without touching v1 setup/import state.
#[arg(long = "import-history")]
import_history: bool,

/// Scaffold only — never prompt to launch REPL/Web UI, even on a TTY.
/// Use in scripts and CI.
#[arg(long = "no-launch")]
no_launch: bool,
}

/// Surface an operator can launch straight after onboarding.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LaunchSurface {
Repl,
WebUi,
}

/// Map a prompt answer to a launch surface. `None` = skip / launch nothing.
fn parse_launch_choice(input: &str) -> Option<LaunchSurface> {
match input.trim().to_ascii_lowercase().as_str() {
"1" | "repl" | "r" => Some(LaunchSurface::Repl),
"2" | "webui" | "web" | "w" => Some(LaunchSurface::WebUi),
_ => None,
}
}

impl OnboardCommand {
Expand Down Expand Up @@ -60,19 +82,82 @@ impl OnboardCommand {
println!("- onboarding completion marker available");
println!();
println!("remaining:");
println!("- configure LLM credentials through env vars referenced by config.toml");
println!(
"- run `ironclaw-reborn models set-provider <provider> --model <model>` as needed"
);
println!("- choose a model (in the REPL prompt or the Web UI setup screen below)");
if self.import_history {
println!("- history import requested but not wired yet");
} else {
println!("- history import not requested");
}

// Offer to launch straight into a usable surface, where model setup
// happens (Web UI setup screen / REPL prompt). Skipped for --no-launch
// and non-interactive stdin so scripts/CI keep the scaffold-only path.
if !self.no_launch && io::stdin().is_terminal() {
if let Some(surface) = prompt_launch_surface()? {
return launch_surface(surface);
}
} else {
println!();
println!("next: run `ironclaw-reborn repl` or `ironclaw-reborn serve` to finish setup");
}
Comment on lines +95 to +102

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When onboarding is run interactively on a TTY and the user chooses to skip the launch prompt (or enters an invalid choice), the command exits silently without printing the next steps. The instructions on how to manually start the REPL or Web UI are only printed in the else block when the prompt is skipped entirely (e.g., via --no-launch or non-interactive stdin).

Removing the else constraint ensures that the manual setup instructions are always printed if the automatic launch is skipped or not executed.

        if !self.no_launch && io::stdin().is_terminal() {
            if let Some(surface) = prompt_launch_surface()? {
                return launch_surface(surface);
            }
        }
        println!();
        println!("next: run `ironclaw-reborn repl` or `ironclaw-reborn serve` to finish setup");

Ok(())
}
}

/// Whether this binary was compiled with the Web UI (`serve`) subcommand.
const WEBUI_AVAILABLE: bool = cfg!(feature = "webui-v2-beta");

fn prompt_launch_surface() -> anyhow::Result<Option<LaunchSurface>> {
if WEBUI_AVAILABLE {
print!("\nStart now? [1] REPL [2] Web UI [Enter] skip: ");
} else {
print!(
"\nStart now? [1] REPL [Enter] skip\n\
(Web UI needs a build with `--features webui-v2-beta`): "
);
}
io::stdout().flush()?;
let mut line = String::new();
if io::stdin().read_line(&mut line)? == 0 {
return Ok(None); // EOF (piped input)
}
match parse_launch_choice(&line) {
Some(LaunchSurface::WebUi) if !WEBUI_AVAILABLE => {
println!(
"Web UI is not available in this build. Rebuild with \
`cargo build --features webui-v2-beta`, or choose REPL."
);
Ok(None)
}
other => Ok(other),
}
}

/// Re-exec this same binary into the chosen surface so onboarding hands off to a
/// live session (REPL prompt or Web UI, where the model is configured).
fn launch_surface(surface: LaunchSurface) -> anyhow::Result<()> {
let exe = std::env::current_exe()?;
let subcommand = match surface {
LaunchSurface::Repl => "repl",
LaunchSurface::WebUi => "serve",
};
println!("launching `{subcommand}`…");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// exec replaces this process; only returns on failure.
Err(std::process::Command::new(&exe)
.arg(subcommand)
.exec()
.into())
}
#[cfg(not(unix))]
{
let status = std::process::Command::new(&exe).arg(subcommand).status()?;
std::process::exit(status.code().unwrap_or(1));
}
}

pub(crate) fn onboarding_marker_path(home: &RebornHome) -> PathBuf {
home.path().join(ONBOARDING_MARKER_FILE)
}
Expand Down Expand Up @@ -139,3 +224,21 @@ fn pending_steps(import_history: bool) -> Vec<&'static str> {
}
steps
}

#[cfg(test)]
mod launch_choice_tests {
use super::*;

#[test]
fn parses_repl_webui_and_skip_answers() {
for a in ["1", "repl", "REPL", " r ", "\tRepl\n"] {
assert_eq!(parse_launch_choice(a), Some(LaunchSurface::Repl), "{a:?}");
}
for a in ["2", "webui", "web", "W"] {
assert_eq!(parse_launch_choice(a), Some(LaunchSurface::WebUi), "{a:?}");
}
for a in ["", "\n", "3", "quit", "xyz"] {
assert_eq!(parse_launch_choice(a), None, "{a:?}");
}
}
}
176 changes: 176 additions & 0 deletions crates/ironclaw_reborn_cli/src/commands/repl.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::io::{self, IsTerminal, Write};

use clap::Args;

use crate::context::RebornCliContext;
Expand All @@ -9,11 +11,21 @@ pub(crate) struct ReplCommand {
/// Confirm trusted-laptop host filesystem access for local-dev-yolo.
#[arg(long = "confirm-host-access")]
confirm_host_access: bool,

/// Skip the first-run model-setup prompt shown when no LLM is configured.
#[arg(long = "no-setup")]
no_setup: bool,
}

impl ReplCommand {
pub(crate) fn execute(self, context: RebornCliContext) -> anyhow::Result<()> {
crate::runtime::init_tracing();
// First-run model setup: only when interactive and not opted out. Runs
// before the runtime starts so the chosen provider/key are live for the
// session. Skipped for pipes/CI so scripted `repl` keeps working.
if !self.no_setup && io::stdin().is_terminal() {
maybe_run_model_setup(&context)?;
}
crate::runtime::execute(
context,
None,
Expand All @@ -23,3 +35,167 @@ impl ReplCommand {
)
}
}

/// Parse a provider-menu answer. `None` = skip; otherwise a 1-based index in
/// range.
fn parse_provider_choice(input: &str, count: usize) -> Option<usize> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
trimmed
.parse::<usize>()
.ok()
.filter(|n| *n >= 1 && *n <= count)
}

#[cfg(not(feature = "root-llm-provider"))]
fn maybe_run_model_setup(_context: &RebornCliContext) -> anyhow::Result<()> {
Ok(())
}

#[cfg(feature = "root-llm-provider")]
fn maybe_run_model_setup(context: &RebornCliContext) -> anyhow::Result<()> {
use ironclaw_reborn_composition::RebornProviderAdmin;

// An env-driven selection (`LLM_BACKEND=...`) is already active — don't nag.
if std::env::var("LLM_BACKEND")
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
return Ok(());
}

let admin = RebornProviderAdmin::new(context.boot_config().clone());
if let Some(selection) = admin.status()?.default {
// A provider is already selected. Prompt for its API key only when the
// provider actually *requires* one and the env var is missing —
// otherwise we'd nag for providers that need no key (Ollama) or use a
// different credential (nearai / OAuth providers authenticate via a
// session token or login, not `*_API_KEY`).
if let Some(env_var) = selection.api_key_env.as_deref()
&& std::env::var_os(env_var).is_none()
&& provider_requires_api_key(&admin, selection.provider_id.as_deref())
{
prompt_and_set_api_key(env_var, selection.provider_id.as_deref());
}
return Ok(());
}

let providers = admin.list(None, false)?.providers;
if providers.is_empty() {
return Ok(());
}

println!("\nNo AI model configured yet. Choose a provider to get started");
println!(
"(press Enter to skip and set one later with `ironclaw-reborn models set-provider`):\n"
);
for (idx, provider) in providers.iter().enumerate() {
println!(
" {}) {:<20} {}",
idx + 1,
provider.id,
provider.description
);
}
// Re-prompt on an invalid entry; only an empty line skips and EOF exits.
let index = loop {
print!("\nProvider [1-{} / Enter to skip]: ", providers.len());
io::stdout().flush()?;
let mut answer = String::new();
if io::stdin().read_line(&mut answer)? == 0 {
return Ok(()); // EOF
}
if answer.trim().is_empty() {
return Ok(()); // skip
}
if let Some(index) = parse_provider_choice(&answer, providers.len()) {
break index;
}
println!("please enter a number between 1 and {}.", providers.len());
};
let provider = &providers[index - 1];

print!("Model [Enter for default `{}`]: ", provider.default_model);
io::stdout().flush()?;
let mut model = String::new();
io::stdin().read_line(&mut model)?;
let model = model.trim();
let model_arg = (!model.is_empty()).then_some(model);

let outcome = admin.set_provider(&provider.id, model_arg)?;
println!(
"configured: provider `{}`, model `{}`",
outcome.provider_id, outcome.model
);

if outcome.missing_api_key
&& let Some(env_var) = outcome.api_key_env.as_deref()
{
prompt_and_set_api_key(env_var, Some(&outcome.provider_id));
}
println!();
Ok(())
}

/// Whether the given provider genuinely requires an API key (vs. Ollama, or a
/// session-token / OAuth provider like nearai). Looks up the provider's
/// metadata; defaults to `false` so an unknown/unreadable provider never nags.
#[cfg(feature = "root-llm-provider")]
fn provider_requires_api_key(
admin: &ironclaw_reborn_composition::RebornProviderAdmin,
provider_id: Option<&str>,
) -> bool {
let Some(id) = provider_id else {
return false;
};
admin
.list(Some(id), true)
.ok()
.and_then(|list| list.providers.into_iter().next())
.and_then(|provider| provider.metadata)
.map(|metadata| metadata.api_key_required)
.unwrap_or(false)
}
Comment on lines +146 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

.ok() swallows the provider-metadata read without a silent-ok marker. admin.list(...).ok() drops the error on a settings read and continues with false. The default-false behavior is defensible here (an unreadable provider just shouldn't nag), but the guideline requires the fallback be named explicitly.

As per coding guidelines: "justified fallbacks must include an inline // silent-ok: <reason> comment naming the operation."

♻️ Add the marker
     let Some(id) = provider_id else {
         return false;
     };
+    // silent-ok: provider-metadata lookup — an unreadable/unknown provider just
+    // means "don't nag for a key", not a lost authoritative read.
     admin
         .list(Some(id), true)
         .ok()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn provider_requires_api_key(
admin: &ironclaw_reborn_composition::RebornProviderAdmin,
provider_id: Option<&str>,
) -> bool {
let Some(id) = provider_id else {
return false;
};
admin
.list(Some(id), true)
.ok()
.and_then(|list| list.providers.into_iter().next())
.and_then(|provider| provider.metadata)
.map(|metadata| metadata.api_key_required)
.unwrap_or(false)
}
fn provider_requires_api_key(
admin: &ironclaw_reborn_composition::RebornProviderAdmin,
provider_id: Option<&str>,
) -> bool {
let Some(id) = provider_id else {
return false;
};
// silent-ok: provider-metadata lookup — an unreadable/unknown provider just
// means "don't nag for a key", not a lost authoritative read.
admin
.list(Some(id), true)
.ok()
.and_then(|list| list.providers.into_iter().next())
.and_then(|provider| provider.metadata)
.map(|metadata| metadata.api_key_required)
.unwrap_or(false)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_cli/src/commands/repl.rs` around lines 146 - 160,
Update provider_requires_api_key at the admin.list(Some(id), true).ok() call to
add an inline “silent-ok” comment explicitly stating that unreadable provider
metadata intentionally defaults to false and avoids prompting for an API key.

Source: Coding guidelines


/// Prompt for an API key and set it in the process env for this session.
#[cfg(feature = "root-llm-provider")]
fn prompt_and_set_api_key(env_var: &str, provider: Option<&str>) {
let label = provider.unwrap_or("the selected provider");
print!(
"\n{label} needs an API key. Enter value for `{env_var}` (used this session; Enter to skip): "
);
if io::stdout().flush().is_err() {
return;
}
let mut key = String::new();
if io::stdin().read_line(&mut key).is_err() {
return;
}
let key = key.trim();
if key.is_empty() {
println!("no key entered — set `{env_var}` in your environment before chatting.");
return;
}
// SAFETY: single-threaded CLI startup, before the runtime spawns any threads
// that read process environment variables.
unsafe { std::env::set_var(env_var, key) };
println!("key set for this session. To persist it, add `export {env_var}=...` to your shell.");
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn provider_choice_parses_index_and_skip() {
assert_eq!(parse_provider_choice("1", 3), Some(1));
assert_eq!(parse_provider_choice(" 3 \n", 3), Some(3));
assert_eq!(parse_provider_choice("", 3), None); // Enter = skip
assert_eq!(parse_provider_choice("\n", 3), None);
assert_eq!(parse_provider_choice("0", 3), None); // out of range
assert_eq!(parse_provider_choice("4", 3), None); // out of range
assert_eq!(parse_provider_choice("openai", 3), None); // non-numeric
}
}
Loading
Loading