feat: complete native execution and Steam discovery

This commit is contained in:
2026-07-18 20:11:52 -04:00
parent 514f87dacb
commit bc0090c63d
42 changed files with 6403 additions and 97 deletions
+447 -17
View File
@@ -1,36 +1,466 @@
//! Private smoke CLI for the Project Kiln workspace.
//! Project Kiln command-line entry point.
use std::env;
use std::process::ExitCode;
use kiln_core::CONTRACT_SCHEMA_VERSION;
use kiln_adapter_native::{NativeAdapter, current_log_path, execute};
use kiln_adapter_steam::SteamAdapter;
use kiln_core::adapter::ProviderAdapter;
use kiln_core::config::{
COMMENTED_DEFAULT_CONFIG, CONFIG_SCHEMA_VERSION, ConfigurationSource, ConfigurationTier,
EditableScalar, built_in_configuration, configuration_path_for_current_user,
load_configuration, resolve_layers, set_configuration_scalar,
};
use kiln_core::contract_error::{
CONTRACT_ERROR_SCHEMA_VERSION, ContractErrorCode, ContractErrorV1,
};
use kiln_core::launch::LAUNCH_PLAN_SCHEMA_VERSION;
use kiln_core::registry::INSTALLATION_REGISTRY_SCHEMA_VERSION;
use kiln_core::{CONTRACT_SCHEMA_VERSION, ContractName, ContractText, GameRecordV1};
use serde_json::json;
const HELP: &str = "Project Kiln private scaffold\n\nUSAGE:\n kiln <COMMAND>\n\nCOMMANDS:\n version Print the private build version\n doctor [--json] Check the scaffold contract\n help Print this help\n";
const HELP: &str = "Project Kiln private development CLI
Usage:
kiln doctor [--json]
kiln list [--provider native|steam] [--json]
kiln search <query> [--provider native|steam] [--json]
kiln info <id-or-name> [--provider native|steam] [--json]
kiln scan [--provider native|steam] [--no-cache] [--json]
kiln launch <id-or-name> [--dry-run] [--json]
kiln config defaults
kiln config validate [--json]
kiln config explain [--json]
kiln config set runtime|session-backend <value> [--json]
kiln --help
kiln --version";
fn main() -> ExitCode {
let mut arguments = env::args().skip(1);
match (arguments.next().as_deref(), arguments.next().as_deref()) {
(None | Some("help" | "--help" | "-h"), None) => {
print!("{HELP}");
run(env::args().skip(1).collect())
}
fn run(mut arguments: Vec<String>) -> ExitCode {
let json_output = take_flag(&mut arguments, "--json");
if arguments.first().is_some_and(|argument| argument == "scan") {
let _ = take_flag(&mut arguments, "--no-cache");
}
if let Some(result) = explicit_provider_command(&arguments, json_output) {
return result;
}
match arguments.as_slice() {
[flag] if flag == "--help" || flag == "-h" => {
println!("{HELP}");
ExitCode::SUCCESS
}
(Some("version" | "--version" | "-V"), None) => {
println!("kiln {} (private placeholder)", env!("CARGO_PKG_VERSION"));
[flag] if flag == "--version" || flag == "-V" => {
println!("kiln {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
(Some("doctor"), None) => {
println!("ok: core contract schema {CONTRACT_SCHEMA_VERSION}");
ExitCode::SUCCESS
[command] if command == "doctor" => doctor(json_output),
[command] if command == "list" => with_adapter(json_output, |adapter| {
output_records(adapter.discover()?, json_output);
Ok(())
}),
[command, query] if command == "search" => with_adapter(json_output, |adapter| {
output_records(adapter.search(query)?, json_output);
Ok(())
}),
[command, selector] if command == "info" && selector.starts_with("steam:") => {
with_steam(json_output, |adapter| {
output_record(&adapter.resolve(selector)?, json_output);
Ok(())
})
}
(Some("doctor"), Some("--json")) => {
println!(
"{{\"schema_version\":{CONTRACT_SCHEMA_VERSION},\"status\":\"ok\",\"private_placeholder\":true}}"
);
ExitCode::SUCCESS
[command, selector] if command == "info" => with_adapter(json_output, |adapter| {
output_record(&adapter.resolve(selector)?, json_output);
Ok(())
}),
[command] if command == "scan" => scan_native(json_output),
_ if arguments.first().is_some_and(|value| value == "launch") => {
launch(arguments, json_output)
}
_ if arguments.first().is_some_and(|value| value == "config") => {
config(&arguments, json_output)
}
[command, ..] if command == "frontend" && json_output => {
json_error(&ContractErrorV1::unsupported_capability(
text("frontend capabilities are post-version-1"),
name("frontend"),
))
}
_ if json_output => json_error(&ContractErrorV1::new(
ContractErrorCode::InvalidInput,
text("unsupported arguments"),
)),
_ => {
eprintln!("error: unsupported arguments\n\n{HELP}");
ExitCode::from(2)
}
}
}
fn explicit_provider_command(arguments: &[String], json_output: bool) -> Option<ExitCode> {
match arguments {
[command, provider, value] if command == "list" && provider == "--provider" => {
Some(list_provider(value, json_output))
}
[command, query, provider, value] if command == "search" && provider == "--provider" => {
Some(search_provider(value, query, json_output))
}
[command, selector, provider, value] if command == "info" && provider == "--provider" => {
Some(info_provider(value, selector, json_output))
}
[command, provider, value] if command == "scan" && provider == "--provider" => {
Some(match value.as_str() {
"native" => scan_native(json_output),
"steam" => scan_steam(json_output),
_ => invalid_arguments(json_output),
})
}
_ => None,
}
}
fn list_provider(provider: &str, json_output: bool) -> ExitCode {
match provider {
"native" => with_adapter(json_output, |adapter| {
output_records(adapter.discover()?, json_output);
Ok(())
}),
"steam" => with_steam(json_output, |adapter| {
output_records(adapter.discover()?, json_output);
Ok(())
}),
_ => invalid_arguments(json_output),
}
}
fn search_provider(provider: &str, query: &str, json_output: bool) -> ExitCode {
match provider {
"native" => with_adapter(json_output, |adapter| {
output_records(adapter.search(query)?, json_output);
Ok(())
}),
"steam" => with_steam(json_output, |adapter| {
output_records(adapter.search(query)?, json_output);
Ok(())
}),
_ => invalid_arguments(json_output),
}
}
fn info_provider(provider: &str, selector: &str, json_output: bool) -> ExitCode {
match provider {
"native" => with_adapter(json_output, |adapter| {
output_record(&adapter.resolve(selector)?, json_output);
Ok(())
}),
"steam" => with_steam(json_output, |adapter| {
output_record(&adapter.resolve(selector)?, json_output);
Ok(())
}),
_ => invalid_arguments(json_output),
}
}
fn config(arguments: &[String], json_output: bool) -> ExitCode {
let result = match arguments {
[command, operation] if command == "config" && operation == "defaults" => {
print!("{COMMENTED_DEFAULT_CONFIG}");
return ExitCode::SUCCESS;
}
[command, operation] if command == "config" && operation == "validate" => {
validate_config(json_output)
}
[command, operation] if command == "config" && operation == "explain" => {
explain_config(json_output)
}
[command, operation, field, value] if command == "config" && operation == "set" => {
set_config(field, value, json_output)
}
_ => return invalid_arguments(json_output),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) if json_output => json_error(&error),
Err(error) => {
eprintln!("error: {error}");
ExitCode::from(2)
}
}
}
fn validate_config(json_output: bool) -> Result<(), ContractErrorV1> {
let path = configuration_path_for_current_user().map_err(configuration_error)?;
let layer = load_configuration(
&path,
ConfigurationSource {
tier: ConfigurationTier::GlobalUser,
label: text("global user configuration"),
},
)
.map_err(configuration_error)?;
if json_output {
println!(
"{}",
json!({"status": "ok", "path": path, "exists": layer.is_some()})
);
} else if layer.is_some() {
println!("ok: {}", path.display());
} else {
println!("ok: defaults in use; {} does not exist", path.display());
}
Ok(())
}
fn explain_config(json_output: bool) -> Result<(), ContractErrorV1> {
let path = configuration_path_for_current_user().map_err(configuration_error)?;
let mut layers = vec![built_in_configuration()];
if let Some(layer) = load_configuration(
&path,
ConfigurationSource {
tier: ConfigurationTier::GlobalUser,
label: text("global user configuration"),
},
)
.map_err(configuration_error)?
{
layers.push(layer);
}
let resolved = resolve_layers(&layers).map_err(configuration_error)?;
let output = if json_output {
serde_json::to_string(&resolved)
} else {
serde_json::to_string_pretty(&resolved)
}
.expect("configuration explanation serializes");
println!("{output}");
Ok(())
}
fn set_config(field: &str, raw: &str, json_output: bool) -> Result<(), ContractErrorV1> {
let scalar = match field {
"runtime" => EditableScalar::Runtime,
"session-backend" => EditableScalar::SessionBackend,
_ => {
return Err(ContractErrorV1::new(
ContractErrorCode::InvalidInput,
text("config set supports runtime or session-backend"),
));
}
};
let value: ContractName = raw.parse().map_err(|_| {
ContractErrorV1::new(
ContractErrorCode::InvalidInput,
text("configuration value must be a valid contract name"),
)
})?;
let path = configuration_path_for_current_user().map_err(configuration_error)?;
set_configuration_scalar(&path, scalar, &value).map_err(configuration_error)?;
if json_output {
println!(
"{}",
json!({"status": "updated", "path": path, "field": field, "value": value})
);
} else {
println!("updated {field} in {}", path.display());
}
Ok(())
}
fn configuration_error(error: impl std::fmt::Display) -> ContractErrorV1 {
let message = format!("configuration error: {error}");
ContractErrorV1::new(
ContractErrorCode::InvalidInput,
message
.parse()
.unwrap_or_else(|_| text("configuration operation failed")),
)
}
fn doctor(json_output: bool) -> ExitCode {
if json_output {
println!(
"{}",
json!({
"status": "ok",
"contracts": {
"game_record": CONTRACT_SCHEMA_VERSION,
"installation_registry": INSTALLATION_REGISTRY_SCHEMA_VERSION,
"launch_plan": LAUNCH_PLAN_SCHEMA_VERSION,
"error": CONTRACT_ERROR_SCHEMA_VERSION,
"configuration": CONFIG_SCHEMA_VERSION
}
})
);
} else {
println!("ok: core contract schema {CONTRACT_SCHEMA_VERSION}");
}
ExitCode::SUCCESS
}
fn scan_native(json_output: bool) -> ExitCode {
with_adapter(json_output, |adapter| {
let records = adapter.discover()?;
if json_output {
println!(
"{}",
json!({"provider": "native", "discovered": records.len()})
);
} else {
println!("native: {} installed game(s)", records.len());
}
Ok(())
})
}
fn scan_steam(json_output: bool) -> ExitCode {
with_steam(json_output, |adapter| {
let records = adapter.discover()?;
if json_output {
println!(
"{}",
json!({"provider": "steam", "discovered": records.len()})
);
} else {
println!("steam: {} installed game(s)", records.len());
}
Ok(())
})
}
fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode {
let dry_run = take_flag(&mut arguments, "--dry-run");
let _ = take_flag(&mut arguments, "--no-cache");
let [command, selector] = arguments.as_slice() else {
return invalid_arguments(json_output);
};
if command != "launch" {
return invalid_arguments(json_output);
}
with_adapter(json_output, |adapter| {
let record = adapter.resolve(selector)?;
let plan = adapter.plan_launch(record.id())?;
if dry_run {
if json_output {
println!(
"{}",
serde_json::to_string(&plan).expect("launch plan serializes")
);
} else {
println!(
"{}",
serde_json::to_string_pretty(&plan).expect("launch plan serializes")
);
}
return Ok(());
}
let log = current_log_path()?;
let outcome = execute(&plan, &log)?;
if json_output {
println!(
"{}",
json!({"game_id": record.id(), "status": "exited", "exit_code": outcome.exit_code, "log": log})
);
} else {
println!(
"{} exited successfully; log: {}",
record.id(),
log.display()
);
}
Ok(())
})
}
fn with_adapter(
json_output: bool,
operation: impl FnOnce(&NativeAdapter) -> Result<(), ContractErrorV1>,
) -> ExitCode {
match NativeAdapter::for_current_user().and_then(|adapter| operation(&adapter)) {
Ok(()) => ExitCode::SUCCESS,
Err(error) if json_output => json_error(&error),
Err(error) => {
eprintln!("error: {error}");
ExitCode::from(2)
}
}
}
fn with_steam(
json_output: bool,
operation: impl FnOnce(&SteamAdapter) -> Result<(), ContractErrorV1>,
) -> ExitCode {
match SteamAdapter::for_current_user().and_then(|adapter| operation(&adapter)) {
Ok(()) => ExitCode::SUCCESS,
Err(error) if json_output => json_error(&error),
Err(error) => {
eprintln!("error: {error}");
ExitCode::from(2)
}
}
}
fn output_records(records: Vec<GameRecordV1>, json_output: bool) {
if json_output {
println!(
"{}",
serde_json::to_string(&records).expect("records serialize")
);
} else {
for record in records {
println!("{}\t{}", record.id(), record.name().as_str());
}
}
}
fn output_record(record: &GameRecordV1, json_output: bool) {
if json_output {
println!(
"{}",
serde_json::to_string(record).expect("record serializes")
);
} else {
println!(
"{}\t{}\t{}",
record.id(),
record.name().as_str(),
record.install_path().as_path().display()
);
}
}
fn invalid_arguments(json_output: bool) -> ExitCode {
let error = ContractErrorV1::new(
ContractErrorCode::InvalidInput,
text("unsupported arguments"),
);
if json_output {
json_error(&error)
} else {
eprintln!("error: unsupported arguments\n\n{HELP}");
ExitCode::from(2)
}
}
fn take_flag(arguments: &mut Vec<String>, flag: &str) -> bool {
let found = arguments.iter().any(|argument| argument == flag);
arguments.retain(|argument| argument != flag);
found
}
fn json_error(error: &ContractErrorV1) -> ExitCode {
eprintln!(
"{}",
serde_json::to_string(error).expect("contract error serializes")
);
ExitCode::from(2)
}
fn name(raw: &str) -> ContractName {
raw.parse().expect("static contract name")
}
fn text(raw: &str) -> ContractText {
raw.parse().expect("static contract text")
}