Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
19c3bd3
Claim reading the context window from the server (R5.4)
youkaicountry Nov 7, 2026
f736755
Read the context window from the server (R5.4)
youkaicountry Nov 7, 2026
ef11302
Ask every OpenAI-compatible service for its window, and test the sett…
youkaicountry Nov 7, 2026
8be1ef4
Record the live llama-swap and Ollama window checks (R5.4)
youkaicountry Nov 7, 2026
6ec66be
Choose a context budget from presets and show what it works out to
youkaicountry Nov 7, 2026
d1d181b
Keep a budget set on a paid service up to its window, not the 150k de…
youkaicountry Nov 8, 2026
6a70501
Ask llama-swap only its list about a model it hasn't loaded
youkaicountry Nov 8, 2026
05948f3
Fail the unloaded-model test when nothing is asked
youkaicountry Nov 8, 2026
ae9fd2c
Translate the context budget's strings into Estonian
youkaicountry Nov 8, 2026
6ddcfdc
Ask again in Settings about a server that named no window
youkaicountry Nov 8, 2026
7e90b9e
Read the server's window through a paced backend (R5.4)
youkaicountry Nov 10, 2026
e4e772f
Claim the per-AI tool list setting (R5.4)
youkaicountry Nov 8, 2026
4a2a29f
Let each AI's builder tool list be Auto, Lean or Full (R5.4)
youkaicountry Nov 8, 2026
5e2e7bf
Record live builds with the full and lean tool lists (R5.4)
youkaicountry Nov 8, 2026
56a6b12
Escape the pipes in a STATUS table row's code span
youkaicountry Nov 8, 2026
60ea4f6
Translate the tool list's strings into Estonian
youkaicountry Nov 9, 2026
6d6dc5c
Keep the small-model live run open on the roadmap
youkaicountry Nov 9, 2026
1718ea2
Say "until its server says" only when a window would lift the lean list
youkaicountry Nov 10, 2026
06d4eef
Claim the per-AI vision setting (R5.4)
youkaicountry Nov 8, 2026
1b39145
Say per AI whether its model sees pictures
youkaicountry Nov 8, 2026
e7e2d89
Note that a model llama-swap is still starting reads as unknown
youkaicountry Nov 8, 2026
01dcd1d
Tell Claude behind a gateway sees pictures
youkaicountry Nov 9, 2026
a6ca34a
Read whether a paced backend's model sees pictures (R5.4)
youkaicountry Nov 10, 2026
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
101 changes: 100 additions & 1 deletion apps/craft-anything-cli/src/ai_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use crate::output::Format;
use crate::{Cli, INTERRUPTED, emit, fail, make_engine, out};
use clap::{Args, Subcommand};
use craft_anything_ai::{AgentEvent, AiHub, BackendConfig, BackendEntry, BackendKind, BackendOptions, Effort};
use craft_anything_ai::{AgentEvent, AiHub, BackendConfig, BackendEntry, BackendKind, BackendOptions, Effort, ToolList, Vision};
use craft_anything_evidence::ToolError;
use serde_json::{Value, json};
use std::io::{Read as _, Write as _};
Expand Down Expand Up @@ -94,6 +94,17 @@ pub enum AiCommand {
/// Set how hard a Claude backend thinks (the Claude API or Claude Code): low, medium, high,
/// xhigh or max, or `default` for the usual level; leave it out to see the level and choices.
Effort { name: String, level: Option<String> },
/// Set how many input tokens an API backend's conversation may grow to before older parts are
/// trimmed, or `auto` for what its server says it has room for (else the default); leave it
/// out to see the setting.
Context { name: String, tokens: Option<String> },
/// Set which tools a builder on an API backend is offered: `full` (every tool), `lean` (the
/// tools for building, for small local models) or `auto` (every tool from a 64k window, and
/// always on a paid service); leave it out to see the setting and what it works out to.
Tools { name: String, list: Option<String> },
/// Say whether an API backend's model sees pictures: `yes`, `no`, or `auto` (what its server
/// says; Claude does); leave it out to see the setting and what its server says.
Vision { name: String, sees: Option<String> },
/// Send a tiny request and report latency.
Test { name: String },
/// Choose the backend that builds (leave the name out to go back to automatic).
Expand Down Expand Up @@ -405,6 +416,94 @@ pub fn ai(cli: &Cli, c: &AiCommand, f: Format) -> u8 {
Err(e) => fail(&e, f),
}
}
AiCommand::Context { name, tokens: None } => {
let tokens = hub.options().get(name).and_then(|o| o.context_tokens).filter(|n| *n > 0);
match hub.server_window(name) {
Ok(server) => {
let (window, source) = (server.window.map(|w| w.tokens), server.window.map(|w| w.source));
let budget = server.budget(tokens);
emit(
&json!({"name": name, "context_tokens": tokens, "auto": tokens.is_none(), "window": window, "window_source": source, "budget": budget}),
f,
);
0
}
Err(e) => fail(&e, f),
}
}
AiCommand::Context { name, tokens: Some(tokens) } => {
let budget = match tokens.trim() {
"auto" => None,
t => match t.replace(['_', ','], "").parse::<u64>().ok().filter(|n| *n > 0) {
Some(n) => Some(n),
None => {
let e = ToolError::invalid(format!("{t:?} isn't a number of tokens.")).with_remediation("Use a whole number such as 390000, or auto.");
return fail(&e, f);
}
},
};
match hub.set_context_tokens(name, budget) {
Ok(found) => {
emit(&json!({"name": name, "context_tokens": budget, "found": found}), f);
if found { 0 } else { 1 }
}
Err(e) => fail(&e, f),
}
}
AiCommand::Tools { name, list: None } => {
let options = hub.options().get(name).cloned().unwrap_or_default();
let (setting, tokens) = (options.tools.unwrap_or_default(), options.context_tokens.filter(|n| *n > 0));
match hub.server_window(name) {
Ok(server) => {
let list = if server.lean_tools(setting, tokens) { "lean" } else { "full" };
emit(
&json!({"name": name, "tools": setting.as_str(), "auto": setting == ToolList::Auto, "list": list, "budget": server.budget(tokens)}),
f,
);
0
}
Err(e) => fail(&e, f),
}
}
AiCommand::Tools { name, list: Some(list) } => {
let Some(setting) = ToolList::parse(list) else {
let e = ToolError::invalid(format!("{list:?} isn't a tool list.")).with_remediation("Use auto, lean or full.");
return fail(&e, f);
};
match hub.set_tools(name, Some(setting)) {
Ok(found) => {
emit(&json!({"name": name, "tools": setting.as_str(), "found": found}), f);
if found { 0 } else { 1 }
}
Err(e) => fail(&e, f),
}
}
AiCommand::Vision { name, sees: None } => match hub.vision(name) {
Ok((setting, read)) => {
emit(
&json!({
"name": name, "vision": setting.as_str(), "sees": craft_anything_ai::sight::sees(setting, read),
"server_says": read.map(|r| r.sees), "source": read.map(|r| r.source),
}),
f,
);
0
}
Err(e) => fail(&e, f),
},
AiCommand::Vision { name, sees: Some(sees) } => {
let Some(setting) = Vision::parse(sees) else {
let e = ToolError::invalid(format!("{sees:?} isn't a vision setting.")).with_remediation("Use auto, yes or no.");
return fail(&e, f);
};
match hub.set_vision(name, Some(setting)) {
Ok(found) => {
emit(&json!({"name": name, "vision": setting.as_str(), "found": found}), f);
if found { 0 } else { 1 }
}
Err(e) => fail(&e, f),
}
}
AiCommand::Program { name, path } => match hub.set_command(name, path.as_deref()) {
Ok(found) => {
emit(&json!({"name": name, "program": path, "found": found}), f);
Expand Down
66 changes: 66 additions & 0 deletions apps/craft-anything-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,72 @@ fn opencode_cli_lists_supported_depths_and_rejects_unsupported_choices() {
assert_eq!(cli(&["ai", "model", "OpenCode", "test/model", "--default"], cfg.path()).code, Some(2));
}

/// `ai context` shows, sets and clears an API backend's context budget, and refuses what isn't one.
#[test]
fn an_ais_context_budget_is_set_from_the_cli() {
let cfg = tempfile::tempdir().unwrap();
let add = ["ai", "add", "Box", "--kind", "openai_compatible", "--model", "qwen", "--base-url", "http://127.0.0.1:9/v1"];
assert!(cli(&add, cfg.path()).success);
let shown = |cfg: &std::path::Path| -> Value { serde_json::from_slice(&cli(&["ai", "context", "Box", "--format", "json"], cfg).stdout).unwrap() };
// Nothing answers at that address, so no window is known: Auto is the local default.
let auto = shown(cfg.path());
assert_eq!((auto["auto"].clone(), auto["window"].clone(), auto["budget"].clone()), (json!(true), Value::Null, json!(24_000)));
assert!(cli(&["ai", "context", "Box", "390,000"], cfg.path()).success);
assert_eq!(shown(cfg.path())["context_tokens"], 390_000);
assert_eq!(shown(cfg.path())["budget"], 390_000);
assert!(std::fs::read_to_string(cfg.path().join("config.toml")).unwrap().contains("context_tokens = 390000"));
assert!(!cli(&["ai", "context", "Box", "lots"], cfg.path()).success);
assert_eq!(shown(cfg.path())["context_tokens"], 390_000, "a refused value changes nothing");
assert!(cli(&["ai", "context", "Box", "auto"], cfg.path()).success);
assert_eq!(shown(cfg.path())["auto"], true);
assert!(!cli(&["ai", "context", "Nobody"], cfg.path()).success);
}

/// `ai tools` shows and sets which tools a builder is offered, and what Auto works out to.
#[test]
fn an_ais_tool_list_is_set_from_the_cli() {
let cfg = tempfile::tempdir().unwrap();
let add = ["ai", "add", "Box", "--kind", "openai_compatible", "--model", "qwen", "--base-url", "http://127.0.0.1:9/v1"];
assert!(cli(&add, cfg.path()).success);
let shown = |cfg: &std::path::Path| -> Value { serde_json::from_slice(&cli(&["ai", "tools", "Box", "--format", "json"], cfg).stdout).unwrap() };
// No window is known, so Auto works out to the lean list on the local default budget.
let auto = shown(cfg.path());
assert_eq!((auto["tools"].clone(), auto["auto"].clone(), auto["list"].clone()), (json!("auto"), json!(true), json!("lean")));
// A budget of 64k or more is room for every tool.
assert!(cli(&["ai", "context", "Box", "65536"], cfg.path()).success);
assert_eq!(shown(cfg.path())["list"], "full");
assert!(cli(&["ai", "tools", "Box", "lean"], cfg.path()).success);
assert_eq!((shown(cfg.path())["tools"].clone(), shown(cfg.path())["list"].clone()), (json!("lean"), json!("lean")));
assert!(std::fs::read_to_string(cfg.path().join("config.toml")).unwrap().contains("tools = \"lean\""));
assert!(!cli(&["ai", "tools", "Box", "everything"], cfg.path()).success);
assert_eq!(shown(cfg.path())["tools"], "lean", "a refused value changes nothing");
assert!(cli(&["ai", "tools", "Box", "auto"], cfg.path()).success);
assert!(!std::fs::read_to_string(cfg.path().join("config.toml")).unwrap().contains("tools = \"lean\""), "Auto is the default and isn't written");
assert!(!cli(&["ai", "tools", "Nobody"], cfg.path()).success);
}

/// `ai vision` shows and sets whether a model sees pictures, and what its server says.
#[test]
fn whether_an_ai_sees_pictures_is_set_from_the_cli() {
let cfg = tempfile::tempdir().unwrap();
let add = ["ai", "add", "Box", "--kind", "openai_compatible", "--model", "qwen", "--base-url", "http://127.0.0.1:9/v1"];
assert!(cli(&add, cfg.path()).success);
let shown = |cfg: &std::path::Path| -> Value { serde_json::from_slice(&cli(&["ai", "vision", "Box", "--format", "json"], cfg).stdout).unwrap() };
// Nothing answers at that address, so Auto knows nothing and the model isn't shown pictures.
let auto = shown(cfg.path());
assert_eq!((auto["vision"].clone(), auto["sees"].clone(), auto["server_says"].clone()), (json!("auto"), json!(false), Value::Null));
assert!(cli(&["ai", "vision", "Box", "yes"], cfg.path()).success);
assert_eq!((shown(cfg.path())["vision"].clone(), shown(cfg.path())["sees"].clone()), (json!("yes"), json!(true)));
assert!(std::fs::read_to_string(cfg.path().join("config.toml")).unwrap().contains("vision = \"yes\""));
assert!(!cli(&["ai", "vision", "Box", "maybe"], cfg.path()).success);
assert_eq!(shown(cfg.path())["vision"], "yes", "a refused value changes nothing");
assert!(cli(&["ai", "vision", "Box", "no"], cfg.path()).success);
assert_eq!(shown(cfg.path())["sees"], false);
assert!(cli(&["ai", "vision", "Box", "auto"], cfg.path()).success);
assert!(!std::fs::read_to_string(cfg.path().join("config.toml")).unwrap().contains("vision ="), "Auto is the default and isn't written");
assert!(!cli(&["ai", "vision", "Nobody"], cfg.path()).success);
}

#[test]
fn commands_and_exit_codes() {
let cfg = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading