use std::collections::BTreeSet;
use std::path::Path;
use clap::Args;
use serde_json::json;
use crate::api::client::ApiClient;
use crate::api::editor as api_editor;
use crate::cli::commands::shared::make_client;
use crate::cli::GlobalArgs;
use crate::config;
use crate::error::{
OlError, OL_4210_SCHEMA_MISMATCH, OL_4232_BACKEND_UNAUTHORIZED, OL_4263_CONSENT_WRITE_FAILED,
OL_4274_MANIFEST_WRITE_FAILED, OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG,
OL_4281_PLATFORM_DUPLICATE_PROVIDER_SLUG, OL_4282_PLATFORM_DUPLICATE_EDITOR_SLUG,
OL_4283_PLATFORM_DUPLICATE_BINDING,
};
use crate::manifest;
use crate::ui::output::OutputConfig;
#[derive(Args, Debug)]
pub struct InitArgs {
#[arg(long)]
pub force: bool,
#[arg(long, hide = true)]
pub skip_preflight: bool,
#[arg(long)]
pub editor_slug: Option<String>,
#[arg(long)]
pub display_name: Option<String>,
#[arg(long)]
pub description: Option<String>,
#[arg(long)]
pub homepage_url: Option<String>,
#[arg(long)]
pub docs_url: Option<String>,
#[arg(long)]
pub telemetry: bool,
#[arg(long)]
pub no_telemetry: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NextStepBranch {
Wizard,
NewToolTemplate,
EditByHand,
SaveAndExit,
}
#[derive(Debug, Clone, Copy, Default)]
struct BranchCounts {
tools: u32,
providers: u32,
bindings: u32,
}
pub async fn run(g: &GlobalArgs, args: InitArgs) -> Result<(), OlError> {
let out = OutputConfig::resolve(g);
crate::ui::header::print(&out, &["init"]);
let client = if args.skip_preflight {
None
} else {
ensure_logged_in(&out, g).await?;
Some(make_client().await?)
};
let owned_slugs = if args.force {
owned_slugs_from_existing(&args)
} else {
OwnedSlugs::default()
};
let editor = if out.interactive {
prompt_editor(&out, &args, client.as_ref(), &owned_slugs).await?
} else {
let editor = flags_to_editor(&args)?;
validate_editor_slug_or_error(
client.as_ref(),
editor.get("slug").and_then(|v| v.as_str()).unwrap_or(""),
&owned_slugs,
)
.await?;
editor
};
let slug = editor
.get("slug")
.and_then(|v| v.as_str())
.ok_or_else(|| OlError::new(OL_4210_SCHEMA_MISMATCH, "editor.slug missing"))?
.to_string();
let path = config::manifest_path_for_slug(&slug);
if path.exists() && !args.force {
out.print_step(&format!(
"{} already exists — pass --force to overwrite (a .bak copy will be made first).",
path.display()
));
return Ok(());
}
if path.exists() && args.force {
let bak = path.with_extension("yaml.bak");
std::fs::copy(&path, &bak).map_err(|e| {
OlError::new(
OL_4274_MANIFEST_WRITE_FAILED,
format!("backup {}: {e}", bak.display()),
)
})?;
out.print_step(&format!("Backed up existing manifest to {}", bak.display()));
}
let mut manifest_value = json!({
"schema_version": 1,
"editor": editor,
});
write_manifest_value(&path, &manifest_value)?;
out.print_step(&format!("Wrote {}", path.display()));
config::set_manifest_slug(g.profile.as_deref(), &slug)?;
out.print_step(&format!(
"Linked manifest to profile `{}` (config.toml)",
g.profile.as_deref().unwrap_or("default")
));
handle_consent(&out, &args)?;
let counts = if !out.interactive || out.is_quiet() || out.is_machine() {
print_onboarding_summary(&out, &path, BranchCounts::default());
BranchCounts::default()
} else {
let branch = pick_next_step();
match branch {
NextStepBranch::Wizard => {
run_wizard_branch(
&out,
&path,
&mut manifest_value,
client.as_ref(),
&owned_slugs,
)
.await?
}
NextStepBranch::NewToolTemplate => {
run_new_tool_branch(&out, &path);
BranchCounts::default()
}
NextStepBranch::EditByHand => {
run_edit_branch(&out, &path);
BranchCounts::default()
}
NextStepBranch::SaveAndExit => {
print_onboarding_summary(&out, &path, BranchCounts::default());
BranchCounts::default()
}
}
};
crate::telemetry::capture_global(crate::telemetry::Event::init_completed(
true,
counts.tools,
counts.providers,
counts.bindings,
));
Ok(())
}
fn flags_to_editor(args: &InitArgs) -> Result<serde_json::Value, OlError> {
let slug = args.editor_slug.clone().ok_or_else(|| {
OlError::new(
OL_4210_SCHEMA_MISMATCH,
"--non-interactive mode requires --editor-slug",
)
})?;
let display_name = args.display_name.clone().ok_or_else(|| {
OlError::new(
OL_4210_SCHEMA_MISMATCH,
"--non-interactive mode requires --display-name",
)
})?;
let mut editor = json!({
"slug": slug,
"display_name": display_name,
"description": args.description.clone().unwrap_or_else(|| "TBD".into()),
});
if let Some(h) = &args.homepage_url {
editor["homepage_url"] = json!(h);
}
if let Some(d) = &args.docs_url {
editor["docs_url"] = json!(d);
}
Ok(editor)
}
async fn prompt_editor(
out: &OutputConfig,
args: &InitArgs,
client: Option<&ApiClient>,
owned: &OwnedSlugs,
) -> Result<serde_json::Value, OlError> {
use inquire::Text;
explain(
out,
"An Editor is your vendor identity in the OpenLatch marketplace — \
the brand under which you publish detection tools.",
);
let slug = if let Some(s) = args.editor_slug.clone() {
validate_editor_slug_or_error(client, &s, owned).await?;
s
} else {
loop {
let candidate = Text::new("Editor slug:")
.with_validator(slug_validator())
.prompt()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("prompt: {e}")))?;
match check_editor_slug(client, &candidate, owned).await {
Ok(()) => break candidate,
Err(e) if e.is(OL_4282_PLATFORM_DUPLICATE_EDITOR_SLUG) => {
eprintln!(
" ✗ '{candidate}' is taken by another editor — pick a different slug."
);
continue;
}
Err(e) => return Err(e),
}
}
};
let display_name = args.display_name.clone().map_or_else(
|| {
Text::new("Editor display name:")
.with_help_message("Shown in the marketplace catalog")
.prompt()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("prompt: {e}")))
},
Ok,
)?;
let description = args.description.clone().map_or_else(
|| {
Text::new("Description:")
.with_default("TBD")
.prompt()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("prompt: {e}")))
},
Ok,
)?;
let homepage_url = args.homepage_url.clone().map_or_else(
|| {
Text::new("Homepage URL (optional):")
.with_default("")
.prompt()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("prompt: {e}")))
},
Ok,
)?;
let docs_url = args.docs_url.clone().map_or_else(
|| {
Text::new("Docs URL (optional):")
.with_default("")
.prompt()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("prompt: {e}")))
},
Ok,
)?;
let mut editor = json!({
"slug": slug,
"display_name": display_name,
"description": description,
});
if !homepage_url.is_empty() {
editor["homepage_url"] = json!(homepage_url);
}
if !docs_url.is_empty() {
editor["docs_url"] = json!(docs_url);
}
Ok(editor)
}
const PICKER_WIZARD: &str = "Set up a Tool + Provider + Binding now (interactive) (Recommended)";
const PICKER_NEW_TEMPLATE: &str = "Scaffold a starter tool repo (`new tool --template …`)";
const PICKER_EDIT: &str = "Edit ~/.openlatch/provider/<slug>.yaml by hand (opens in $EDITOR)";
const PICKER_SAVE_EXIT: &str = "Save and exit — I'll handle this later";
fn pick_next_step() -> NextStepBranch {
use inquire::Select;
let options = vec![
PICKER_WIZARD,
PICKER_NEW_TEMPLATE,
PICKER_EDIT,
PICKER_SAVE_EXIT,
];
match Select::new("What would you like to do next?", options).prompt() {
Ok(PICKER_WIZARD) => NextStepBranch::Wizard,
Ok(PICKER_NEW_TEMPLATE) => NextStepBranch::NewToolTemplate,
Ok(PICKER_EDIT) => NextStepBranch::EditByHand,
Ok(PICKER_SAVE_EXIT) => NextStepBranch::SaveAndExit,
Ok(_) => NextStepBranch::SaveAndExit,
Err(e) => {
tracing::warn!(error = %e, "picker cancelled, defaulting to save-and-exit");
NextStepBranch::SaveAndExit
}
}
}
async fn run_wizard_branch(
out: &OutputConfig,
path: &Path,
manifest_value: &mut serde_json::Value,
client: Option<&ApiClient>,
owned: &OwnedSlugs,
) -> Result<BranchCounts, OlError> {
let tool = prompt_tool(out, client, owned).await?;
let provider = prompt_provider(out, client, owned).await?;
let binding = prompt_binding(
out,
tool["slug"].as_str().unwrap_or_default(),
provider["slug"].as_str().unwrap_or_default(),
client,
)
.await?;
manifest_value["tools"] = json!([tool]);
manifest_value["providers"] = json!([provider]);
manifest_value["bindings"] = json!([binding]);
manifest::schema::validate(manifest_value)?;
write_manifest_value(path, manifest_value)?;
out.print_step("Manifest: 1 editor · 1 tool · 1 provider · 1 binding");
confirm_and_open_editor(out, path);
print_onboarding_summary(
out,
path,
BranchCounts {
tools: 1,
providers: 1,
bindings: 1,
},
);
Ok(BranchCounts {
tools: 1,
providers: 1,
bindings: 1,
})
}
async fn prompt_tool(
out: &OutputConfig,
client: Option<&ApiClient>,
owned: &OwnedSlugs,
) -> Result<serde_json::Value, OlError> {
use inquire::{MultiSelect, Text};
explain(
out,
"A Tool is one detection capability you offer (e.g. PII scanning, \
credential detection). One logical tool per slug; versions are tracked \
independently when you `publish`.",
);
let slug = loop {
let candidate = Text::new("Tool slug:")
.with_validator(slug_validator())
.with_help_message("Lowercase, ≤63 chars, [a-z0-9-]")
.prompt()
.map_err(prompt_err)?;
match check_tool_slug(client, &candidate, owned).await {
Ok(()) => break candidate,
Err(e) if e.is(OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG) => {
eprintln!(" ✗ tool slug '{candidate}' is already in use — pick another.");
continue;
}
Err(e) => return Err(e),
}
};
let description = Text::new("Tool description:")
.with_default("TBD")
.prompt()
.map_err(prompt_err)?;
let hooks_supported: Vec<String> = loop {
let picks = MultiSelect::new(
"Hook events this tool subscribes to:",
manifest::KNOWN_HOOK_EVENTS.to_vec(),
)
.prompt()
.map_err(prompt_err)?;
if !picks.is_empty() {
break picks.into_iter().map(String::from).collect();
}
eprintln!(" (pick at least one hook event — press space to select)");
};
let agents_supported: Vec<String> = loop {
let picks = MultiSelect::new(
"Agent platforms this tool supports:",
manifest::KNOWN_AGENT_PLATFORMS.to_vec(),
)
.with_default(&[0])
.prompt()
.map_err(prompt_err)?;
if !picks.is_empty() {
break picks.into_iter().map(String::from).collect();
}
eprintln!(" (pick at least one agent platform — press space to select)");
};
let capability = prompt_capability()?;
Ok(json!({
"slug": slug,
"version": "0.1.0",
"license": "apache-2.0",
"description": description,
"hooks_supported": hooks_supported,
"agents_supported": agents_supported,
"capabilities": [capability],
}))
}
fn prompt_capability() -> Result<serde_json::Value, OlError> {
use inquire::{Confirm, Select, Text};
let threat_category = Select::new("Threat category:", threat_category_options())
.prompt()
.map_err(prompt_err)?
.value;
let declared_latency_p95_ms = Text::new("Declared p95 latency (ms, 1..=60000):")
.with_default("200")
.with_validator(int_range_validator(1, 60000))
.prompt()
.map_err(prompt_err)?
.parse::<u64>()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("latency: {e}")))?;
let execution_mode = Select::new("Execution mode:", vec!["sync", "async"])
.prompt()
.map_err(prompt_err)?;
let needs_raw_payload = Confirm::new("Does the tool need the raw (un-redacted) event payload?")
.with_default(false)
.prompt()
.map_err(prompt_err)?;
let needs_prior_config_state =
Confirm::new("Does the tool need prior config-artifact state (stateful config/integrity)?")
.with_default(false)
.prompt()
.map_err(prompt_err)?;
let mut cap = json!({
"threat_category": threat_category,
"declared_latency_p95_ms": declared_latency_p95_ms,
"execution_mode": execution_mode,
"needs_raw_payload": needs_raw_payload,
});
if needs_prior_config_state {
cap["needs_prior_config_state"] = json!(true);
}
Ok(cap)
}
async fn prompt_provider(
out: &OutputConfig,
client: Option<&ApiClient>,
owned: &OwnedSlugs,
) -> Result<serde_json::Value, OlError> {
use inquire::{validator::Validation, Text};
explain(
out,
"A Provider is the compute environment hosting your tool — typically \
named by region and capacity. You can have several providers per tool \
(e.g. one per region) for redundancy.",
);
let slug = loop {
let candidate = Text::new("Provider slug:")
.with_validator(slug_validator())
.with_help_message("Lowercase, ≤63 chars, [a-z0-9-]")
.prompt()
.map_err(prompt_err)?;
match check_provider_slug(client, &candidate, owned).await {
Ok(()) => break candidate,
Err(e) if e.is(OL_4281_PLATFORM_DUPLICATE_PROVIDER_SLUG) => {
eprintln!(" ✗ provider slug '{candidate}' is already in use — pick another.");
continue;
}
Err(e) => return Err(e),
}
};
let display_name = Text::new("Provider display name:")
.with_validator(|s: &str| {
if s.trim().is_empty() {
Ok(Validation::Invalid("display_name cannot be empty".into()))
} else {
Ok(Validation::Valid)
}
})
.prompt()
.map_err(prompt_err)?;
let region = Text::new("Region:")
.with_default("us-east-1")
.prompt()
.map_err(prompt_err)?;
let total_capacity_qps = Text::new("Total capacity (qps, ≥1):")
.with_default("100")
.with_validator(int_range_validator(1, i64::MAX))
.prompt()
.map_err(prompt_err)?
.parse::<u64>()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("capacity: {e}")))?;
let endpoint_url = Text::new("Public HTTPS endpoint (provider):")
.with_help_message("e.g. https://provider.example.com/v1/event — one ingress per provider")
.with_validator(https_url_validator())
.prompt()
.map_err(prompt_err)?;
Ok(json!({
"slug": slug,
"display_name": display_name,
"region": region,
"total_capacity_qps": total_capacity_qps,
"endpoint_url": endpoint_url,
}))
}
async fn prompt_binding(
out: &OutputConfig,
tool_slug: &str,
provider_slug: &str,
client: Option<&ApiClient>,
) -> Result<serde_json::Value, OlError> {
use inquire::Text;
explain(
out,
"A Binding wires one tool to one provider. The platform routes events to \
that provider's HTTPS endpoint using the binding's declared latency, capacity \
and priority — this is what the marketplace's routing engine actually picks.",
);
if let Some(c) = client {
if let Err(e) = api_editor::validate_binding(c, tool_slug, provider_slug).await {
if e.is(OL_4283_PLATFORM_DUPLICATE_BINDING) {
return Err(e.with_suggestion(
"Re-run `init` and choose a different tool or provider slug.",
));
}
return Err(e);
}
}
let declared_latency_p95_ms = Text::new("Binding p95 latency (ms, 1..=60000):")
.with_default("200")
.with_validator(int_range_validator(1, 60000))
.prompt()
.map_err(prompt_err)?
.parse::<u64>()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("latency: {e}")))?;
let capacity_qps = Text::new("Capacity (qps, ≥1):")
.with_default("100")
.with_validator(int_range_validator(1, i64::MAX))
.prompt()
.map_err(prompt_err)?
.parse::<u64>()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("capacity: {e}")))?;
let priority = Text::new("Priority (0..=100):")
.with_default("50")
.with_validator(int_range_validator(0, 100))
.prompt()
.map_err(prompt_err)?
.parse::<u64>()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("priority: {e}")))?;
explain(
out,
"Managed process: the runtime daemon (`openlatch-provider listen`) \
will spawn and supervise your tool. Provide the command to start it \
and the HTTP port it listens on for /healthz probes.",
);
let command_raw = Text::new("Command to start the tool (space-separated argv):")
.with_default("uv run uvicorn my_tool:app --port 8081")
.prompt()
.map_err(prompt_err)?;
let command: Vec<String> = command_raw.split_whitespace().map(str::to_string).collect();
if command.is_empty() {
return Err(OlError::new(
OL_4210_SCHEMA_MISMATCH,
"command must contain at least one token (the program to run)",
));
}
let port = Text::new("Tool localhost port (1..=65535):")
.with_default("8081")
.with_validator(int_range_validator(1, 65535))
.prompt()
.map_err(prompt_err)?
.parse::<u64>()
.map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("port: {e}")))?;
Ok(json!({
"tool": tool_slug,
"provider": provider_slug,
"declared_latency_p95_ms": declared_latency_p95_ms,
"capacity_qps": capacity_qps,
"priority": priority,
"process": {
"command": command,
"health_check": { "http": { "port": port } },
},
}))
}
fn run_new_tool_branch(out: &OutputConfig, path: &Path) {
out.print_info(
"Scaffolding starter tool repos lands in P2.T10 \
(`new tool --template <python|rust|node>`).",
);
out.print_info(
"For now, opening the manifest so you can fill in tools[]/providers[]/bindings[] by hand.",
);
out.print_info("Docs: https://docs.openlatch.ai/cli/new-tool");
run_edit_branch(out, path);
}
fn run_edit_branch(out: &OutputConfig, path: &Path) {
out.print_info("Add three sections to your manifest. The marketplace mental model:");
out.print_substep("editor ✓ already filled in (your vendor identity)");
out.print_substep("tools[] — what you detect (one entry per logical capability)");
out.print_substep("providers[] — where you run (region + capacity + public HTTPS endpoint)");
out.print_substep(
"bindings[] — how the platform routes (per-tool latency / capacity / priority)",
);
confirm_and_open_editor(out, path);
print_onboarding_summary(out, path, BranchCounts::default());
}
fn explain(out: &OutputConfig, msg: &str) {
if !out.interactive || out.is_quiet() || out.is_machine() {
return;
}
out.print_info("");
out.print_info(msg);
}
fn print_onboarding_summary(out: &OutputConfig, manifest_path: &Path, counts: BranchCounts) {
if out.is_quiet() || out.is_machine() {
return;
}
out.print_info("");
out.print_info("✨ You're all set — your editor profile is ready.");
out.print_info("");
out.print_info("Files written:");
out.print_substep(&format!("manifest: {}", manifest_path.display()));
if counts.tools > 0 || counts.providers > 0 || counts.bindings > 0 {
out.print_substep(&format!(
"contains: 1 editor · {} tool(s) · {} provider(s) · {} binding(s) — \
all in the same file",
counts.tools, counts.providers, counts.bindings
));
} else {
out.print_substep(
"contains: 1 editor — add tools[]/providers[]/bindings[] to make it registerable",
);
}
out.print_info("");
out.print_info("What's next:");
out.print_substep(
"1. `openlatch-provider register --dry-run` — preview the diff against the platform \
and probe your binding endpoints client-side. No mutations are sent.",
);
out.print_substep(
"2. `openlatch-provider register` — commit the upsert. The platform will reveal \
one-time webhook signing secrets (whsec_live_…) for each new binding; store them \
immediately, they're never shown again.",
);
out.print_substep(
"3. `openlatch-provider listen --port 8443` — run the runtime daemon on your \
compute to receive HMAC-signed events from the platform.",
);
out.print_info("");
out.print_info("Reference: https://docs.openlatch.ai/manifest");
}
fn confirm_and_open_editor(out: &OutputConfig, path: &Path) {
if !out.interactive || out.is_quiet() || out.is_machine() {
out.print_info(&format!(
"Manifest written. Review it at {} before running `register`.",
path.display()
));
return;
}
out.print_info("");
out.print_info(
"We'll open the manifest in your $EDITOR so you can review and tweak \
it (license, hooks_supported, version, etc.) before registration.",
);
use inquire::Confirm;
let answer = Confirm::new(&format!("Open {} now?", path.display()))
.with_default(true)
.prompt();
match answer {
Ok(true) => open_in_editor(out, path),
Ok(false) => {
out.print_info(&format!(
"Skipped. Open {} manually whenever you're ready.",
path.display()
));
}
Err(e) => {
tracing::warn!(error = %e, "editor confirmation cancelled, skipping launch");
out.print_info(&format!(
"Skipped. Open {} manually whenever you're ready.",
path.display()
));
}
}
}
fn open_in_editor(out: &OutputConfig, path: &Path) {
let editor = std::env::var_os("VISUAL")
.or_else(|| std::env::var_os("EDITOR"))
.unwrap_or_else(|| {
if cfg!(target_os = "windows") {
"notepad".into()
} else {
"vi".into()
}
});
let editor_str = editor.to_string_lossy().into_owned();
let mut parts = editor_str.split_whitespace();
let bin = parts.next().unwrap_or("vi");
let args: Vec<&str> = parts.collect();
match std::process::Command::new(bin)
.args(&args)
.arg(path)
.status()
{
Ok(status) if status.success() => {
out.print_step(&format!("Reviewed in {bin}"));
}
Ok(_) | Err(_) => {
tracing::warn!(
editor = %bin,
path = %path.display(),
"could not launch $EDITOR — falling back to printing path",
);
out.print_info(&format!("Open {} in your editor manually.", path.display()));
}
}
}
#[derive(Debug, Clone)]
struct ThreatChoice {
value: &'static str,
label: &'static str,
}
impl std::fmt::Display for ThreatChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label)
}
}
fn threat_category_options() -> Vec<ThreatChoice> {
vec![
ThreatChoice {
value: "pii_outbound",
label: "PII outbound — personal data leaving the agent",
},
ThreatChoice {
value: "pii_inbound",
label: "PII inbound — personal data entering via tool responses / user input",
},
ThreatChoice {
value: "credential_detection",
label: "Credentials & secrets — API keys, tokens, passwords in payloads",
},
ThreatChoice {
value: "injection_tool_response",
label: "Prompt injection — adversarial content inside tool responses",
},
ThreatChoice {
value: "injection_user_input",
label: "Prompt injection — adversarial content in user input",
},
ThreatChoice {
value: "shell_dangerous",
label: "Dangerous shell commands — destructive or sandbox-escape patterns",
},
ThreatChoice {
value: "shell_exfiltration",
label: "Shell exfiltration — outbound data transfer via shell commands",
},
ThreatChoice {
value: "tool_hash_verification",
label: "Tool hash verification — tool binary / definition integrity drift",
},
ThreatChoice {
value: "tool_poison_detection",
label: "Tool / MCP poisoning — malicious or tampered tool definitions",
},
ThreatChoice {
value: "tool_typosquatting",
label: "Tool typosquatting — lookalike / impersonating tool names",
},
ThreatChoice {
value: "attack_path_analysis",
label: "Attack-path analysis — chained risky actions across an agent run",
},
ThreatChoice {
value: "configuration_threat",
label: "Configuration threat — risky config / hooks / skill changes",
},
]
}
fn slug_validator(
) -> impl Fn(&str) -> Result<inquire::validator::Validation, inquire::CustomUserError> + Clone + 'static
{
use inquire::validator::Validation;
|s: &str| {
let re = regex::Regex::new(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$").unwrap();
if re.is_match(s) {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(
"slug must be lowercase, ≤63 chars, [a-z0-9-]".into(),
))
}
}
}
fn https_url_validator(
) -> impl Fn(&str) -> Result<inquire::validator::Validation, inquire::CustomUserError> + Clone + 'static
{
use inquire::validator::Validation;
|s: &str| {
if s.starts_with("https://") && s.len() > "https://".len() {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(
"endpoint_url must start with https:// (the platform never connects to plaintext)"
.into(),
))
}
}
}
fn int_range_validator(
min: i64,
max: i64,
) -> impl Fn(&str) -> Result<inquire::validator::Validation, inquire::CustomUserError> + Clone + 'static
{
use inquire::validator::Validation;
move |s: &str| match s.trim().parse::<i64>() {
Ok(n) if n >= min && n <= max => Ok(Validation::Valid),
Ok(_) => Ok(Validation::Invalid(
format!("must be an integer in {min}..={max}").into(),
)),
Err(_) => Ok(Validation::Invalid("must be an integer".into())),
}
}
fn prompt_err(e: inquire::InquireError) -> OlError {
OlError::new(OL_4210_SCHEMA_MISMATCH, format!("prompt: {e}"))
}
fn write_manifest_value(path: &Path, value: &serde_json::Value) -> Result<(), OlError> {
let yaml = serde_yaml::to_string(value).map_err(|e| {
OlError::new(
OL_4274_MANIFEST_WRITE_FAILED,
format!("serialise manifest: {e}"),
)
})?;
write_manifest(path, yaml.as_bytes())
}
fn write_manifest(path: &Path, bytes: &[u8]) -> Result<(), OlError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OlError::new(
OL_4274_MANIFEST_WRITE_FAILED,
format!("create parent {}: {e}", parent.display()),
)
})?;
}
let tmp = path.with_extension("yaml.tmp");
std::fs::write(&tmp, bytes).map_err(|e| {
OlError::new(
OL_4274_MANIFEST_WRITE_FAILED,
format!("write {}: {e}", tmp.display()),
)
})?;
std::fs::rename(&tmp, path).map_err(|e| {
OlError::new(
OL_4274_MANIFEST_WRITE_FAILED,
format!("rename {}: {e}", path.display()),
)
})
}
async fn ensure_logged_in(out: &OutputConfig, g: &GlobalArgs) -> Result<(), OlError> {
if let Some(identity) = super::auth::current_identity().await {
let email = identity.email.as_deref().unwrap_or("?");
let org = identity
.organization_name
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("(no org)");
out.print_step(&format!("Already authenticated as {email} ({org})"));
return Ok(());
}
if !out.interactive {
return Err(OlError::new(
OL_4232_BACKEND_UNAUTHORIZED,
"init requires authentication; set OPENLATCH_TOKEN or run \
`openlatch-provider login` first",
)
.with_suggestion(
"In CI: provide OPENLATCH_TOKEN. Locally: run `openlatch-provider login` once.",
));
}
super::auth::login(
g,
super::auth::LoginArgs {
token_file: None,
auth_url: None,
},
)
.await
}
#[derive(Debug, Default, Clone)]
struct OwnedSlugs {
editor: Option<String>,
tools: BTreeSet<String>,
providers: BTreeSet<String>,
}
impl OwnedSlugs {
fn editor_matches(&self, slug: &str) -> bool {
self.editor.as_deref() == Some(slug)
}
fn owns_tool(&self, slug: &str) -> bool {
self.tools.contains(slug)
}
fn owns_provider(&self, slug: &str) -> bool {
self.providers.contains(slug)
}
}
fn owned_slugs_from_existing(args: &InitArgs) -> OwnedSlugs {
let Some(slug) = args.editor_slug.as_deref() else {
return OwnedSlugs::default();
};
let path = config::manifest_path_for_slug(slug);
if !path.exists() {
return OwnedSlugs::default();
}
let Ok(m) = manifest::load(&path) else {
return OwnedSlugs::default();
};
OwnedSlugs {
editor: Some((*m.editor.slug).to_string()),
tools: m.tools.iter().map(|t| (*t.slug).to_string()).collect(),
providers: m.providers.iter().map(|p| (*p.slug).to_string()).collect(),
}
}
async fn check_editor_slug(
client: Option<&ApiClient>,
slug: &str,
owned: &OwnedSlugs,
) -> Result<(), OlError> {
if owned.editor_matches(slug) {
return Ok(());
}
let Some(c) = client else { return Ok(()) };
api_editor::validate_editor_slug(c, slug).await
}
async fn check_tool_slug(
client: Option<&ApiClient>,
slug: &str,
owned: &OwnedSlugs,
) -> Result<(), OlError> {
if owned.owns_tool(slug) {
return Ok(());
}
let Some(c) = client else { return Ok(()) };
let body = json!({ "slug": slug });
api_editor::validate_tool(c, &body).await
}
async fn check_provider_slug(
client: Option<&ApiClient>,
slug: &str,
owned: &OwnedSlugs,
) -> Result<(), OlError> {
if owned.owns_provider(slug) {
return Ok(());
}
let Some(c) = client else { return Ok(()) };
let body = json!({ "slug": slug });
api_editor::validate_provider(c, &body).await
}
async fn validate_editor_slug_or_error(
client: Option<&ApiClient>,
slug: &str,
owned: &OwnedSlugs,
) -> Result<(), OlError> {
if slug.is_empty() {
return Ok(());
}
check_editor_slug(client, slug, owned).await
}
fn handle_consent(out: &OutputConfig, args: &InitArgs) -> Result<(), OlError> {
let consent_path = crate::config::provider_dir().join("telemetry.json");
if args.telemetry {
return crate::telemetry::consent_file::write_consent(&consent_path, true);
}
if args.no_telemetry {
return crate::telemetry::consent_file::write_consent(&consent_path, false);
}
if let Ok(Some(_)) = crate::telemetry::consent_file::read_consent(&consent_path) {
return Ok(());
}
if !out.interactive {
return crate::telemetry::consent_file::write_consent(&consent_path, false);
}
use inquire::Confirm;
let answer = Confirm::new(
"Send anonymous usage telemetry to PostHog (EU)? See \
https://docs.openlatch.ai/telemetry for what's collected.",
)
.with_default(true)
.prompt()
.map_err(|e| OlError::new(OL_4263_CONSENT_WRITE_FAILED, format!("consent prompt: {e}")))?;
crate::telemetry::consent_file::write_consent(&consent_path, answer)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn threat_category_options_match_known_categories() {
use std::collections::BTreeSet;
let from_picker: BTreeSet<&str> =
threat_category_options().iter().map(|c| c.value).collect();
let from_const: BTreeSet<&str> =
manifest::KNOWN_THREAT_CATEGORIES.iter().copied().collect();
assert_eq!(
from_picker, from_const,
"init wizard threat_category_options() drifted from KNOWN_THREAT_CATEGORIES"
);
}
}