/** * Pure, framework-free helpers for the first-run wizard (testable without React * or Tauri, ARCHITECTURE §1.3). Validation of a custom/edited profile and small * factories live here; the components only render. */ import type { AgentProfile, ContextInjection, HttpChatConfig, InjectionStrategy, OpenCodeConfig, } from "@/domain"; /** A field-keyed validation error map (empty ⇒ valid). */ export type ProfileErrors = Partial< Record< | "name" | "command" | "target" | "flag" | "var" | "endpoint" | "baseURL" | "model" | "apiKeyEnv" | "requestTimeoutMs" | "connectTimeoutMs" | "maxToolIterations", string > >; /** Whether a path is a relative, traversal-free file name (mirror of backend). */ export function isRelativeSafe(path: string): boolean { if (path.length === 0) return false; if (path.startsWith("/") || path.startsWith("\\")) return false; // Windows drive (C:) / UNC. if (/^[a-zA-Z]:/.test(path)) return false; return !path.split(/[/\\]/).includes(".."); } /** Whether a string is a valid environment-variable identifier. */ export function isValidEnvVar(v: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(v); } /** * Whether a string is a valid `http://`/`https://` endpoint (mirror of the * backend `HttpChatConfig::new`: non-empty once trimmed, and the raw value * starts with `http://` or `https://`). */ export function isValidHttpUrl(v: string): boolean { if (v.trim().length === 0) return false; return v.startsWith("http://") || v.startsWith("https://"); } /** * Whether a value is an acceptable positive-integer timeout/guard (mirror of the * backend, which rejects `0` and only ever holds `u32`/`u16`). `undefined` means * "left blank" and is valid (the backend applies its own default). */ function isValidPositiveInt(value: number | undefined, max: number): boolean { if (value === undefined) return true; return Number.isInteger(value) && value > 0 && value <= max; } const MAX_U32 = 0xffff_ffff; const MAX_U16 = 0xffff; /** * Validates an {@link HttpChatConfig} draft the way the backend * `HttpChatConfig::new` would, so the wizard can surface errors before any * `invoke`. Returns an empty object when the draft is valid. */ export function validateHttpChatConfig(c: HttpChatConfig): ProfileErrors { const errors: ProfileErrors = {}; if (!isValidHttpUrl(c.endpoint)) { errors.endpoint = "Endpoint must start with http:// or https://."; } if (c.model.trim().length === 0) { errors.model = "Model is required."; } // The API key field holds the NAME of an environment variable, never a key. if (c.apiKeyEnv !== undefined && c.apiKeyEnv.length > 0 && !isValidEnvVar(c.apiKeyEnv)) { errors.apiKeyEnv = "Must be a valid env var name (not the key itself)."; } if (!isValidPositiveInt(c.requestTimeoutMs, MAX_U32)) { errors.requestTimeoutMs = "Must be a positive integer (ms)."; } if (!isValidPositiveInt(c.connectTimeoutMs, MAX_U32)) { errors.connectTimeoutMs = "Must be a positive integer (ms)."; } if (!isValidPositiveInt(c.maxToolIterations, MAX_U16)) { errors.maxToolIterations = "Must be a positive integer."; } return errors; } /** * Validates an {@link OpenCodeConfig} draft the way the backend * `OpenCodeConfig::new` would (base URL http/https + non-empty, model non-empty; * the API key is a free-form, optional secret). Returns an empty object when the * draft is valid. */ export function validateOpenCodeConfig(c: OpenCodeConfig): ProfileErrors { const errors: ProfileErrors = {}; if (!isValidHttpUrl(c.baseURL)) { errors.baseURL = "Base URL must start with http:// or https://."; } if (c.model.trim().length === 0) { errors.model = "Model is required."; } return errors; } /** * Validates a profile draft the way the backend would, so the wizard can surface * errors before any `invoke`. Returns an empty object when the draft is valid. */ export function validateProfile(p: AgentProfile): ProfileErrors { const errors: ProfileErrors = {}; if (p.name.trim().length === 0) errors.name = "Name is required."; if (p.command.trim().length === 0) errors.command = "Command is required."; const ci = p.contextInjection; if (ci.strategy === "conventionFile") { if (!isRelativeSafe(ci.target)) { errors.target = "Target must be a relative file name (no .. or absolute)."; } } else if (ci.strategy === "flag") { if (ci.flag.trim().length === 0) errors.flag = "Flag is required."; } else if (ci.strategy === "env") { if (!isValidEnvVar(ci.var)) errors.var = "Must be a valid env var identifier."; } // An OpenAI-compatible profile additionally carries an HTTP chat config; the // backend refuses to persist it unless it is well-formed, so mirror that here. if (p.structuredAdapter === "openAiCompatible") { if (!p.chatHttp) { errors.endpoint = "Endpoint must start with http:// or https://."; errors.model = "Model is required."; } else { Object.assign(errors, validateHttpChatConfig(p.chatHttp)); } } // An OpenCode profile carries its own llama.cpp endpoint config (local mode) // or a cloud provider config (`opencodeProvider`, ticket #92) — never both, // and the cloud sub-form owns its own submit-time validation (provider, // model, API key), so only the local-mode shape is mirrored here. if (p.structuredAdapter === "openCode" && !p.opencodeProvider) { if (!p.opencode) { errors.baseURL = "Base URL must start with http:// or https://."; errors.model = "Model is required."; } else { Object.assign(errors, validateOpenCodeConfig(p.opencode)); } } return errors; } /** Whether a profile draft is valid (no errors). */ export function isProfileValid(p: AgentProfile): boolean { return Object.keys(validateProfile(p)).length === 0; } /** * Builds a default {@link ContextInjection} for a strategy, so switching the * strategy dropdown produces a sensible editable shape. */ export function defaultInjection(strategy: InjectionStrategy): ContextInjection { switch (strategy) { case "conventionFile": return { strategy, target: "CONTEXT.md" }; case "flag": return { strategy, flag: "--context-file {path}" }; case "env": return { strategy, var: "AGENT_CONTEXT_FILE" }; case "stdin": return { strategy }; } } /** * A sensible default {@link HttpChatConfig} for a fresh OpenAI-compatible * profile draft — pre-fills a local Ollama endpoint so the form starts valid. * Mirrors the backend reference profile (`http://localhost:11434/v1`). */ export function defaultHttpChatConfig(): HttpChatConfig { return { endpoint: "http://localhost:11434/v1", model: "qwen2.5-coder", requestTimeoutMs: 120_000, connectTimeoutMs: 5_000, maxToolIterations: 16, }; } /** * A sensible default {@link OpenCodeConfig} for a fresh OpenCode profile draft — * pre-fills a local `llama-server` endpoint so the form starts valid. Mirrors the * backend reference profile (`http://localhost:8080/v1`, `qwen3-coder-30b`). */ export function defaultOpenCodeConfig(): OpenCodeConfig { return { baseURL: "http://localhost:8080/v1", apiKey: "sk-no-key", model: "qwen3-coder-30b", }; } /** A fresh, empty custom-profile draft (id minted client-side). */ export function emptyCustomProfile(): AgentProfile { return { id: newProfileId(), name: "", command: "", args: [], contextInjection: { strategy: "conventionFile", target: "CONTEXT.md" }, detect: null, cwdTemplate: "{projectRoot}", }; } /** Generates a UUID for a client-created profile (crypto when available). */ export function newProfileId(): string { const c = globalThis.crypto as Crypto | undefined; if (c && typeof c.randomUUID === "function") return c.randomUUID(); // Fallback for non-secure contexts/tests. return `profile-${Math.random().toString(36).slice(2, 10)}`; } /** Parses a whitespace-separated args string into a trimmed, non-empty list. */ export function parseArgs(raw: string): string[] { return raw .split(/\s+/) .map((s) => s.trim()) .filter((s) => s.length > 0); }