diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e062701..e294d32 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,8 +13,8 @@ JST is a Cargo workspace with three crates: ```text jst natural language request - → POST /translate - → OpenAI-compatible LLM API + → hosted provider: POST /translate → OpenAI-compatible LLM API + → Apple provider (macOS 27 beta): bundled Swift helper → FoundationModels system model → command + concrete effect description + optional semantic parts → local denylist OR dangerous model effects → optional interactive session: explain, revise, manually replace, @@ -42,6 +42,17 @@ complete replacement command and recalculate its effects. The replacement goes through the same server validation, local denylist, terminal-safety checks, and explicit approval loop as the initial command. +`--provider apple` is an explicit macOS 27.0-beta-or-later option. The CLI +serializes the normal system and user prompts to the adjacent +`jst-apple-intelligence` executable over stdin and reads a structured response +from stdout. The Swift helper uses `FoundationModels` directly and checks +`SystemLanguageModel.default.availability` before making a request. No Rust +FFI, provider key, request logging, JST-server quota, or network hop is +involved. The helper is built and signed beside the universal Rust executable; +the release package must keep both files together. Homebrew places the helper +in its private `libexec` directory, which the CLI also locates. Linux and +Windows releases do not contain the helper and retain the hosted provider flow. + Choosing `e` opens a prefilled inline editor with the cursor at the end. Enter counts as execution approval and the edit remains entirely local: it is never sent to the server or model. Safe edits run immediately; edits that match the @@ -89,7 +100,8 @@ successful translations only. ## Workspace ```text -crates/cli/src/main.rs argument parsing, API calls, interactive loop, execution +crates/cli/src/main.rs argument parsing, provider calls, interactive loop, execution +crates/apple-intelligence/main.swift macOS 27 FoundationModels helper crates/cli/src/installation.rs anonymous installation ID persistence crates/cli/src/safety.rs deterministic destructive-command denylist crates/server/src/main.rs HTTP server and routes @@ -110,4 +122,5 @@ JST_API_URL=http://localhost:8080/translate cargo run -p jst-cli -- pwd ``` The CLI contains no provider credentials. Release binaries can be built and -signed as ordinary Rust executables. +signed as ordinary Rust executables. macOS packages also contain the separately +signed Apple Intelligence helper, built by `scripts/build-apple-intelligence-helper.sh`. diff --git a/Cargo.lock b/Cargo.lock index 9308d2e..9abe774 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -644,6 +644,7 @@ dependencies = [ "jst-shared", "regex", "reqwest", + "serde", "serde_json", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 719bd60..b4c70b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,5 +5,5 @@ resolver = "2" [workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } diff --git a/README.md b/README.md index 7750366..4f8b672 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,51 @@ Use `--dry` to print a generated command and exit without running it: jst --dry show the current directory ``` +### Apple Intelligence (macOS 27 beta) + +On macOS 27.0 beta or later, use the on-device Apple Intelligence model instead +of the hosted JST server. It is opt-in; the hosted provider remains JST's +unchanged default: + +```sh +jst --provider apple --dry show the current directory +jst --provider apple -i find files larger than 500 MB +jst --provider apple --status +``` + +If you use it regularly, set the provider once in your shell configuration: + +```sh +export JST_PROVIDER=apple +jst show the current directory +``` + +Use `--provider server` on an individual command to override that setting. + +This option requires a Mac and Apple Intelligence configuration for which the +system model is available. `jst --provider apple --status` reports the model's +availability before any translation. The request, generated command, and +revision instructions stay on the Mac: this provider does not contact the JST +server, consume its quota, or use an API key. + +On macOS 26 or earlier (and on non-macOS platforms), selecting Apple mode exits +before launching the helper with a clear macOS-27 requirement. Run JST without +the Apple provider, or use `--provider server`, to keep using the hosted model. + +Mac release archives include a `jst-apple-intelligence` companion executable; +Linux and Windows archives do not. Homebrew installs it privately and wires it +up automatically. For a manual macOS install, keep the two archive executables +together in the same directory on your `PATH`: + +```sh +install -m 755 jst-*/jst jst-*/jst-apple-intelligence ~/.local/bin/ +``` + +The Rust CLI sends the companion a JSON request and receives a structured JSON +response; the companion calls Apple's `FoundationModels` framework directly. +This keeps the beta framework out of Rust and avoids maintaining Swift +bindings. The hosted JST server is available on every supported platform. + ### Review and refine Use `-i` or `--interactive` to inspect and refine a command before anything diff --git a/crates/apple-intelligence/main.swift b/crates/apple-intelligence/main.swift new file mode 100644 index 0000000..cd81fed --- /dev/null +++ b/crates/apple-intelligence/main.swift @@ -0,0 +1,210 @@ +import Foundation +import FoundationModels + +// This executable is intentionally a tiny process boundary. jst talks to it +// using JSON so the Rust CLI does not need Swift bindings or a Rust wrapper for +// Apple's beta-only FoundationModels framework. + +private struct Request: Decodable { + let systemPrompt: String + let userPrompt: String + let explain: Bool +} + +private struct Response: Encodable { + let command: String + let effects: Effects + let matchesRequest: Bool + let explanation: String + let parts: [Part] + + enum CodingKeys: String, CodingKey { + case command + case effects + case matchesRequest = "matches_request" + case explanation + case parts + } +} + +private struct Effects: Encodable { + let readsData: Bool + let modifiesData: Bool + let deletesData: Bool + let usesNetwork: Bool + let changesRemoteData: Bool + let changesProcesses: Bool + let installsSoftware: Bool + let usesPrivilege: Bool + let executesRemoteCode: Bool + + enum CodingKeys: String, CodingKey { + case readsData = "reads_data" + case modifiesData = "modifies_data" + case deletesData = "deletes_data" + case usesNetwork = "uses_network" + case changesRemoteData = "changes_remote_data" + case changesProcesses = "changes_processes" + case installsSoftware = "installs_software" + case usesPrivilege = "uses_privilege" + case executesRemoteCode = "executes_remote_code" + } +} + +private struct Part: Encodable { + let fragment: String + let meaning: String + let source: String +} + +@Generable(description: "A complete, safety-described shell-command translation for JST.") +private struct GeneratedTranslation { + @Guide(description: "One complete executable shell command. Use '# unable to translate' when no safe, compatible translation is possible.") + var command: String + + @Guide(description: "Concrete effects of running the command.") + var effects: GeneratedEffects + + @Guide(description: "True only when the command completely implements the request.") + var matchesRequest: Bool + + @Guide(description: "A short standalone explanation of what the command does.") + var explanation: String +} + +@Generable(description: "A complete, safety-described shell-command translation for JST with semantic command fragments.") +private struct GeneratedDetailedTranslation { + @Guide(description: "One complete executable shell command. Use '# unable to translate' when no safe, compatible translation is possible.") + var command: String + + @Guide(description: "Concrete effects of running the command.") + var effects: GeneratedEffects + + @Guide(description: "True only when the command completely implements the request.") + var matchesRequest: Bool + + @Guide(description: "A short standalone explanation of what the command does.") + var explanation: String + + @Guide(description: "One to eight command fragments in order. Their fragments must concatenate exactly to command.", .maximumCount(8)) + var parts: [GeneratedPart] +} + +@Generable(description: "Concrete effects of a shell command.") +private struct GeneratedEffects { + var readsData: Bool + var modifiesData: Bool + var deletesData: Bool + var usesNetwork: Bool + var changesRemoteData: Bool + var changesProcesses: Bool + var installsSoftware: Bool + var usesPrivilege: Bool + var executesRemoteCode: Bool +} + +@Generable(description: "A semantic fragment of the generated command.") +private struct GeneratedPart { + var fragment: String + var meaning: String + var source: String +} + +@main +private struct AppleIntelligenceHelper { + static func main() async { + do { + if CommandLine.arguments.dropFirst().first == "--status" { + try writeJSON(["status": availabilityStatus()]) + return + } + + guard case .available = SystemLanguageModel.default.availability else { + throw HelperError.unavailable(availabilityStatus()) + } + + let request = try JSONDecoder().decode( + Request.self, + from: FileHandle.standardInput.readDataToEndOfFile() + ) + let session = LanguageModelSession(instructions: request.systemPrompt) + let response: Response + if request.explain { + let generated = try await session.respond( + to: request.userPrompt, + generating: GeneratedDetailedTranslation.self + ).content + response = Response( + command: generated.command, + effects: effects(for: generated.effects), + matchesRequest: generated.matchesRequest, + explanation: generated.explanation, + parts: generated.parts.map { + Part(fragment: $0.fragment, meaning: $0.meaning, source: $0.source) + } + ) + } else { + let generated = try await session.respond( + to: request.userPrompt, + generating: GeneratedTranslation.self + ).content + response = Response( + command: generated.command, + effects: effects(for: generated.effects), + matchesRequest: generated.matchesRequest, + explanation: generated.explanation, + parts: [] + ) + } + try writeJSON(response) + } catch { + FileHandle.standardError.write( + Data(("jst-apple-intelligence: \(error.localizedDescription)\n").utf8) + ) + exit(1) + } + } + + private static func availabilityStatus() -> String { + switch SystemLanguageModel.default.availability { + case .available: + return "available" + case .unavailable(let reason): + return "unavailable: \(reason)" + @unknown default: + return "unavailable: unknown reason" + } + } + + private static func writeJSON(_ value: T) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(value) + FileHandle.standardOutput.write(data) + } + + private static func effects(for generated: GeneratedEffects) -> Effects { + Effects( + readsData: generated.readsData, + modifiesData: generated.modifiesData, + deletesData: generated.deletesData, + usesNetwork: generated.usesNetwork, + changesRemoteData: generated.changesRemoteData, + changesProcesses: generated.changesProcesses, + installsSoftware: generated.installsSoftware, + usesPrivilege: generated.usesPrivilege, + executesRemoteCode: generated.executesRemoteCode + ) + } +} + +private enum HelperError: LocalizedError { + case unavailable(String) + + var errorDescription: String? { + switch self { + case .unavailable(let status): + return "Apple Intelligence is \(status)" + } + } +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index dff6f05..f6d5e03 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -9,10 +9,11 @@ path = "src/main.rs" [dependencies] jst-shared = { path = "../shared" } +serde.workspace = true serde_json.workspace = true tokio.workspace = true reqwest.workspace = true -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } regex = "1" getrandom = "0.3" crossterm = "0.28" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 418f079..0dab36a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,16 +1,17 @@ mod installation; mod safety; -use clap::{CommandFactory, Parser}; +use clap::{CommandFactory, Parser, ValueEnum}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use jst_shared::{ - CommandEffects, CommandPart, CommandRevision, ServerStatusResponse, TranslateRequest, - TranslateResponse, + build_system_prompt, build_user_prompt, CommandEffects, CommandPart, CommandRevision, + ServerStatusResponse, TranslateRequest, TranslateResponse, }; +use serde::Serialize; use std::fmt; use std::io::{self, IsTerminal, Write}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -21,6 +22,18 @@ const INSTALLATION_ID_HEADER: &str = "x-jst-installation-id"; const CONFIRMATION_WIDTH: usize = 88; const MAX_MANUAL_COMMAND_BYTES: usize = 2 * 1024; const MAX_REVISION_INSTRUCTION_BYTES: usize = 512; +const MAX_HELPER_ERROR_BYTES: usize = 8 * 1024; +const APPLE_TRANSLATION_TIMEOUT: Duration = Duration::from_secs(90); +const APPLE_STATUS_TIMEOUT: Duration = Duration::from_secs(5); +const APPLE_HELPER_NAME: &str = "jst-apple-intelligence"; +const APPLE_MODEL_NAME: &str = "Apple Intelligence (on-device)"; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +enum Provider { + #[default] + Server, + Apple, +} #[derive(Parser, Debug)] #[command( @@ -42,10 +55,14 @@ struct Cli { #[arg(long, conflicts_with = "interactive")] dry: bool, - /// Check server health, models, and aggregate usage + /// Check the selected provider #[arg(long, conflicts_with_all = ["yolo", "interactive", "dry", "prompt"])] status: bool, + /// Translation provider: hosted JST server or Apple Intelligence on this Mac + #[arg(long, value_enum, env = "JST_PROVIDER", default_value_t)] + provider: Provider, + /// What you want to do, in plain English #[arg(required_unless_present = "status", num_args = 1.., trailing_var_arg = true)] prompt: Vec, @@ -56,6 +73,7 @@ enum JstError { Network, Server(u16), LlmProvider, + AppleIntelligence(String), Deserialization, Other(String), } @@ -72,6 +90,7 @@ impl fmt::Display for JstError { "rate limit reached — slow down, or run your own jst server" ), JstError::LlmProvider => write!(f, "trouble reaching the LLM; try again in a moment"), + JstError::AppleIntelligence(message) => write!(f, "Apple Intelligence: {message}"), JstError::Server(code) => write!( f, "the jst server is having trouble (HTTP {code}); try again in a moment" @@ -108,8 +127,10 @@ async fn run() -> Result<(), JstError> { let cli = Cli::parse(); if cli.status { - let status = fetch_server_status().await?; - return print_server_status(&status); + return match cli.provider { + Provider::Server => print_server_status(&fetch_server_status().await?), + Provider::Apple => print_apple_intelligence_status().await, + }; } let input = cli.prompt.join(" "); @@ -122,9 +143,10 @@ async fn run() -> Result<(), JstError> { )); } - let response = translate_with_spinner(&input, interactive, None, use_color).await?; + let response = + translate_with_spinner(&input, interactive, None, use_color, cli.provider).await?; if interactive { - return review_command(&input, response, false, use_color).await; + return review_command(&input, response, false, use_color, cli.provider).await; } let command = validated_command(&response)?; @@ -168,6 +190,7 @@ async fn translate_with_spinner( explain: bool, revision: Option, use_color: bool, + provider: Provider, ) -> Result { let spinner = if use_color { let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel(); @@ -191,7 +214,7 @@ async fn translate_with_spinner( None }; - let result = translate(input, explain, revision).await; + let result = translate(input, explain, revision, provider).await; if let Some((handle, stop_tx)) = spinner { let _ = stop_tx.send(()); @@ -207,6 +230,7 @@ async fn translate( input: &str, explain: bool, revision: Option, + provider: Provider, ) -> Result { let request = TranslateRequest { input: input.to_string(), @@ -215,6 +239,13 @@ async fn translate( explain, revision, }; + match provider { + Provider::Server => translate_with_server(request).await, + Provider::Apple => translate_with_apple_intelligence(request).await, + } +} + +async fn translate_with_server(request: TranslateRequest) -> Result { let api_url = std::env::var("JST_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()); let client = http_client(Duration::from_secs(30))?; let installation_id = @@ -240,6 +271,278 @@ async fn translate( serde_json::from_str(&body).map_err(|_| JstError::Deserialization) } +#[derive(Serialize)] +struct AppleIntelligenceRequest { + #[serde(rename = "systemPrompt")] + system_prompt: String, + #[serde(rename = "userPrompt")] + user_prompt: String, + explain: bool, +} + +async fn translate_with_apple_intelligence( + request: TranslateRequest, +) -> Result { + ensure_apple_intelligence_supported()?; + + let helper = apple_helper_path()?; + let input = serde_json::to_vec(&AppleIntelligenceRequest { + system_prompt: build_system_prompt( + request.os.as_deref(), + request.shell.as_deref(), + request.explain, + ), + user_prompt: build_user_prompt(&request.input, request.revision.as_ref()), + explain: request.explain, + }) + .map_err(|_| JstError::Other("could not encode Apple Intelligence request".to_string()))?; + let output = run_apple_helper( + helper, + &[], + Some(&input), + MAX_RESPONSE_BYTES, + APPLE_TRANSLATION_TIMEOUT, + ) + .await?; + if !output.status.success() { + return Err(JstError::AppleIntelligence(helper_error_message( + &output.stderr, + ))); + } + + let response = serde_json::from_slice(&output.stdout).map_err(|_| { + JstError::AppleIntelligence("returned an invalid structured response".to_string()) + })?; + validate_apple_response(response) +} + +fn validate_apple_response(mut response: TranslateResponse) -> Result { + if response.command.is_empty() || response.command.len() > MAX_MANUAL_COMMAND_BYTES { + return Err(JstError::AppleIntelligence( + "returned an invalid command".to_string(), + )); + } + if response.explanation.len() > 1024 { + return Err(JstError::AppleIntelligence( + "returned an invalid explanation".to_string(), + )); + } + if response.parts.len() > 8 { + response.parts.clear(); + } + Ok(response) +} + +async fn print_apple_intelligence_status() -> Result<(), JstError> { + ensure_apple_intelligence_supported()?; + let output = run_apple_helper( + apple_helper_path()?, + &["--status"], + None, + MAX_STATUS_RESPONSE_BYTES, + APPLE_STATUS_TIMEOUT, + ) + .await?; + if !output.status.success() { + return Err(JstError::AppleIntelligence(helper_error_message( + &output.stderr, + ))); + } + let status: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|_| { + JstError::AppleIntelligence("returned an invalid status response".to_string()) + })?; + let availability = status + .get("status") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + JstError::AppleIntelligence("returned an invalid status response".to_string()) + })?; + let mut stdout = io::stdout().lock(); + writeln!(stdout, "Provider: Apple Intelligence") + .and_then(|_| writeln!(stdout, "Model: {APPLE_MODEL_NAME}")) + .and_then(|_| writeln!(stdout, "Availability: {}", terminal_safe(availability))) + .and_then(|_| writeln!(stdout, "Network: not used")) + .map_err(|error| JstError::Other(format!("{error}"))) +} + +struct AppleHelperOutput { + status: std::process::ExitStatus, + stdout: Vec, + stderr: Vec, +} + +async fn run_apple_helper( + helper: PathBuf, + arguments: &[&str], + input: Option<&[u8]>, + stdout_limit: usize, + timeout: Duration, +) -> Result { + use tokio::io::AsyncWriteExt; + + let mut command = tokio::process::Command::new(helper); + command.args(arguments); + command.stdin(std::process::Stdio::piped()); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + command.kill_on_drop(true); + let mut child = command.spawn().map_err(|error| { + JstError::AppleIntelligence(format!("could not start the bundled helper: {error}")) + })?; + if let Some(input) = input { + let mut stdin = child.stdin.take().ok_or_else(|| { + JstError::AppleIntelligence("could not open the helper input".to_string()) + })?; + stdin.write_all(input).await.map_err(|error| { + JstError::AppleIntelligence(format!( + "could not send the request to the helper: {error}" + )) + })?; + } + drop(child.stdin.take()); + let stdout = child.stdout.take().ok_or_else(|| { + JstError::AppleIntelligence("could not open the helper output".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + JstError::AppleIntelligence("could not open the helper error output".to_string()) + })?; + + let result = tokio::time::timeout(timeout, async { + tokio::try_join!( + read_limited_stream(stdout, stdout_limit), + read_limited_stream(stderr, MAX_HELPER_ERROR_BYTES), + child.wait(), + ) + }) + .await; + let (stdout, stderr, status) = match result { + Ok(Ok((stdout, stderr, status))) => (stdout, stderr, status), + Ok(Err(error)) => { + let _ = child.kill().await; + return Err(JstError::AppleIntelligence(format!( + "could not read the helper response: {error}" + ))); + } + Err(_) => { + let _ = child.kill().await; + return Err(JstError::AppleIntelligence(format!( + "did not respond within {} seconds", + timeout.as_secs() + ))); + } + }; + Ok(AppleHelperOutput { + status, + stdout, + stderr, + }) +} + +async fn read_limited_stream(mut stream: R, limit: usize) -> io::Result> +where + R: tokio::io::AsyncRead + Unpin, +{ + use tokio::io::AsyncReadExt; + + let mut output = Vec::new(); + let mut chunk = [0; 8 * 1024]; + loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + return Ok(output); + } + if output.len() + read > limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "helper response exceeded the allowed size", + )); + } + output.extend_from_slice(&chunk[..read]); + } +} + +fn apple_helper_path() -> Result { + if let Some(path) = std::env::var_os("JST_APPLE_INTELLIGENCE_HELPER") { + return Ok(PathBuf::from(path)); + } + let executable = std::env::current_exe().map_err(|error| { + JstError::AppleIntelligence(format!("could not find the JST executable: {error}")) + })?; + let candidates = apple_helper_candidates(&executable); + if let Some(helper) = candidates.iter().find(|path| path.is_file()) { + return Ok(helper.clone()); + } + let locations = candidates + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(" or "); + Err(JstError::AppleIntelligence(format!( + "the bundled helper is missing at {locations}; reinstall the macOS package or set JST_APPLE_INTELLIGENCE_HELPER for a development build" + ))) +} + +fn ensure_apple_intelligence_supported() -> Result<(), JstError> { + if std::env::consts::OS != "macos" { + return Err(JstError::AppleIntelligence( + "is only supported on macOS 27.0 or later; use --provider server instead".to_string(), + )); + } + + let output = Command::new("sw_vers") + // macOS can report a compatibility version to older processes. JST is + // built for macOS 27, so request the actual product version explicitly. + .env_remove("SYSTEM_VERSION_COMPAT") + .arg("-productVersion") + .output() + .map_err(|_| { + JstError::AppleIntelligence( + "requires macOS 27.0 or later, but the macOS version could not be determined" + .to_string(), + ) + })?; + let version = String::from_utf8_lossy(&output.stdout); + let Some(major) = macos_major_version(&version) else { + return Err(JstError::AppleIntelligence( + "requires macOS 27.0 or later, but the macOS version could not be determined" + .to_string(), + )); + }; + if major < 27 { + return Err(JstError::AppleIntelligence(format!( + "requires macOS 27.0 or later (this Mac is running {}); use --provider server instead", + version.trim() + ))); + } + Ok(()) +} + +fn macos_major_version(version: &str) -> Option { + version.trim().split('.').next()?.parse().ok() +} + +fn apple_helper_candidates(executable: &Path) -> Vec { + let mut candidates = vec![executable.with_file_name(APPLE_HELPER_NAME)]; + if let Some(prefix) = executable.parent().and_then(Path::parent) { + candidates.push(prefix.join("libexec").join(APPLE_HELPER_NAME)); + } + candidates +} + +fn helper_error_message(stderr: &[u8]) -> String { + let stderr = String::from_utf8_lossy(stderr); + let message = stderr + .trim() + .strip_prefix("jst-apple-intelligence:") + .unwrap_or(stderr.trim()) + .trim(); + if message.is_empty() { + "the helper failed without an error message".to_string() + } else { + terminal_safe(message) + } +} + async fn fetch_server_status() -> Result { let api_url = std::env::var("JST_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()); let status_url = std::env::var("JST_STATUS_URL") @@ -317,6 +620,7 @@ async fn review_command( mut response: TranslateResponse, mut explanation_visible: bool, use_color: bool, + provider: Provider, ) -> Result<(), JstError> { let width = terminal_width(); let mut source_context = input.to_string(); @@ -391,7 +695,8 @@ async fn review_command( instruction: instruction.clone(), }; response = - translate_with_spinner(input, true, Some(revision), use_color).await?; + translate_with_spinner(input, true, Some(revision), use_color, provider) + .await?; source_context = format!("{input} {instruction}"); proposal_kind = ProposalKind::Revised; eprintln!(); @@ -1217,16 +1522,18 @@ fn format_error(error: &JstError, color: bool) -> String { #[cfg(test)] mod tests { use super::{ - clean_command, contains_unsafe_terminal_character, format_detailed_explanation, - format_edit_prompt, format_error, format_proposal_explanation, format_review_prompt, - format_server_status, format_warning, indent_wrapped, next_char_end, parse_review_action, - previous_char_start, should_confirm, status_url_for, terminal_safe, Cli, JstError, - ProposalKind, ReviewAction, + apple_helper_candidates, clean_command, contains_unsafe_terminal_character, + format_detailed_explanation, format_edit_prompt, format_error, format_proposal_explanation, + format_review_prompt, format_server_status, format_warning, helper_error_message, + indent_wrapped, macos_major_version, next_char_end, parse_review_action, + previous_char_start, read_limited_stream, should_confirm, status_url_for, terminal_safe, + validate_apple_response, Cli, JstError, ProposalKind, Provider, ReviewAction, }; use clap::{error::ErrorKind, CommandFactory, Parser}; use jst_shared::{ CommandEffects, CommandPart, ServerStatusResponse, StatusUsage, TranslateResponse, }; + use std::path::{Path, PathBuf}; #[test] fn strips_markdown_fences() { @@ -1319,6 +1626,24 @@ mod tests { assert!(cli.dry); } + #[test] + fn accepts_the_apple_provider_before_the_prompt() { + let cli = Cli::try_parse_from([ + "jst", + "--provider", + "apple", + "--dry", + "show", + "the", + "current", + "directory", + ]) + .expect("valid Apple provider invocation"); + + assert_eq!(cli.provider, Provider::Apple); + assert_eq!(cli.prompt.join(" "), "show the current directory"); + } + #[test] fn accepts_interactive_before_prompt() { let cli = Cli::try_parse_from(["jst", "--interactive", "show", "current", "directory"]) @@ -1423,6 +1748,88 @@ mod tests { assert!(!should_confirm(true, &["local"], &["model"])); } + #[test] + fn limits_apple_responses_to_the_server_contract() { + let response = TranslateResponse { + command: "x".repeat(2 * 1024 + 1), + effects: CommandEffects::default(), + matches_request: true, + explanation: String::new(), + parts: Vec::new(), + }; + + assert!(validate_apple_response(response).is_err()); + } + + #[test] + fn falls_back_to_an_unstructured_explanation_when_there_are_too_many_parts() { + let response = TranslateResponse { + command: "pwd".to_string(), + effects: CommandEffects::default(), + matches_request: true, + explanation: "Print the current directory.".to_string(), + parts: (0..9) + .map(|_| CommandPart { + fragment: "pwd".to_string(), + meaning: "Print the current directory.".to_string(), + source: "model".to_string(), + }) + .collect(), + }; + + let response = validate_apple_response(response).expect("valid response"); + assert!(response.parts.is_empty()); + } + + #[tokio::test] + async fn stops_reading_a_helper_stream_at_its_limit() { + use tokio::io::AsyncWriteExt; + + let (mut writer, reader) = tokio::io::duplex(32); + let write = tokio::spawn(async move { + writer.write_all(b"123456789").await.expect("write stream"); + }); + + assert!(read_limited_stream(reader, 8).await.is_err()); + write.await.expect("writer task"); + } + + #[test] + fn removes_the_helper_error_prefix() { + assert_eq!( + helper_error_message(b"jst-apple-intelligence: model not enabled\n"), + "model not enabled" + ); + } + + #[test] + fn finds_the_helper_in_release_and_homebrew_layouts() { + let release = apple_helper_candidates(Path::new("/Applications/jst")); + assert_eq!( + release, + vec![ + PathBuf::from("/Applications/jst-apple-intelligence"), + PathBuf::from("/libexec/jst-apple-intelligence"), + ] + ); + + let homebrew = apple_helper_candidates(Path::new("/opt/homebrew/Cellar/jst/0.3.2/bin/jst")); + assert_eq!( + homebrew, + vec![ + PathBuf::from("/opt/homebrew/Cellar/jst/0.3.2/bin/jst-apple-intelligence"), + PathBuf::from("/opt/homebrew/Cellar/jst/0.3.2/libexec/jst-apple-intelligence"), + ] + ); + } + + #[test] + fn parses_macos_major_versions_for_the_apple_preflight() { + assert_eq!(macos_major_version("27.0\n"), Some(27)); + assert_eq!(macos_major_version("26.4"), Some(26)); + assert_eq!(macos_major_version("not a version"), None); + } + #[test] fn parses_review_actions_with_safe_defaults() { assert_eq!(parse_review_action("y"), Some(ReviewAction::Run)); diff --git a/crates/server/src/openai_compatible.rs b/crates/server/src/openai_compatible.rs index 5a4d4d3..3600637 100644 --- a/crates/server/src/openai_compatible.rs +++ b/crates/server/src/openai_compatible.rs @@ -1,4 +1,4 @@ -use jst_shared::{build_system_prompt, TranslateRequest, TranslateResponse}; +use jst_shared::{build_system_prompt, build_user_prompt, TranslateRequest, TranslateResponse}; use serde::{Deserialize, Serialize}; use std::time::Duration; use tracing::warn; @@ -233,7 +233,7 @@ async fn call_llm( if demo_mode { system_prompt.push_str(DEMO_SYSTEM_ADDENDUM); } - let user_prompt = user_prompt(req); + let user_prompt = build_user_prompt(&req.input, req.revision.as_ref()); let chat_request = ChatRequest { model: model.to_string(), @@ -309,17 +309,6 @@ async fn call_llm( }) } -fn user_prompt(req: &TranslateRequest) -> String { - let Some(revision) = &req.revision else { - return req.input.clone(); - }; - - format!( - "TASK: revise_command\nORIGINAL_REQUEST:\n{}\nCURRENT_COMMAND:\n{}\nREQUESTED_CHANGE:\n{}", - req.input, revision.command, revision.instruction - ) -} - fn validate_translation_response(response: &TranslateResponse) -> Result<(), &'static str> { if response.command.is_empty() || response.command.len() > MAX_COMMAND_BYTES { return Err("LLM command exceeded size limit"); @@ -445,7 +434,7 @@ async fn read_limited_body( mod tests { use super::{ sanitize_explanation_parts, strip_code_fence, translate, translate_with_timeout, - user_prompt, validate_translation_response, MODEL_TIMEOUT, + validate_translation_response, MODEL_TIMEOUT, }; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use jst_shared::{ @@ -480,7 +469,7 @@ mod tests { }), }; - let prompt = user_prompt(&request); + let prompt = jst_shared::build_user_prompt(&request.input, request.revision.as_ref()); assert!(prompt.starts_with("TASK: revise_command\n")); assert!(prompt.contains("ORIGINAL_REQUEST:\nshow large files")); assert!(prompt.contains("CURRENT_COMMAND:\ndu -ah . | sort -hr")); diff --git a/crates/shared/src/prompt.rs b/crates/shared/src/prompt.rs index f23c9e9..9ce4b8e 100644 --- a/crates/shared/src/prompt.rs +++ b/crates/shared/src/prompt.rs @@ -126,9 +126,21 @@ No markdown, code fences, commentary, or additional keys."#.to_string() sections.join("\n\n") } +pub fn build_user_prompt(input: &str, revision: Option<&crate::CommandRevision>) -> String { + let Some(revision) = revision else { + return input.to_string(); + }; + + format!( + "TASK: revise_command\nORIGINAL_REQUEST:\n{}\nCURRENT_COMMAND:\n{}\nREQUESTED_CHANGE:\n{}", + input, revision.command, revision.instruction + ) +} + #[cfg(test)] mod tests { - use super::build_system_prompt; + use super::{build_system_prompt, build_user_prompt}; + use crate::CommandRevision; #[test] fn includes_target_environment_and_required_effects() { @@ -177,4 +189,20 @@ mod tests { assert!(prompt.contains("\"parts\":[")); assert!(prompt.contains("\"source\":\"...\"")); } + + #[test] + fn structures_revisions_without_concatenating_instructions() { + let prompt = build_user_prompt( + "show large files", + Some(&CommandRevision { + command: "du -ah . | sort -hr".to_string(), + instruction: "only show the first ten".to_string(), + }), + ); + + assert!(prompt.starts_with("TASK: revise_command\n")); + assert!(prompt.contains("ORIGINAL_REQUEST:\nshow large files")); + assert!(prompt.contains("CURRENT_COMMAND:\ndu -ah . | sort -hr")); + assert!(prompt.contains("REQUESTED_CHANGE:\nonly show the first ten")); + } } diff --git a/scripts/build-apple-intelligence-helper.sh b/scripts/build-apple-intelligence-helper.sh new file mode 100755 index 0000000..db79e28 --- /dev/null +++ b/scripts/build-apple-intelligence-helper.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE="$ROOT/crates/apple-intelligence/main.swift" +OUTPUT="${1:?pass the helper output path}" +TARGETS=(arm64 x86_64) + +[[ -f "$SOURCE" ]] || { + echo "error: $SOURCE not found" >&2 + exit 1 +} + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf -- "$TEMP_DIR"' EXIT + +for arch in "${TARGETS[@]}"; do + xcrun --sdk macosx swiftc \ + -O \ + -parse-as-library \ + -target "$arch-apple-macos27.0" \ + "$SOURCE" \ + -o "$TEMP_DIR/jst-apple-intelligence-$arch" +done + +mkdir -p "$(dirname "$OUTPUT")" +lipo -create \ + "$TEMP_DIR/jst-apple-intelligence-arm64" \ + "$TEMP_DIR/jst-apple-intelligence-x86_64" \ + -output "$OUTPUT" +chmod 755 "$OUTPUT" diff --git a/scripts/build-macos-release.sh b/scripts/build-macos-release.sh index 65eae6e..6c53fc4 100755 --- a/scripts/build-macos-release.sh +++ b/scripts/build-macos-release.sh @@ -24,8 +24,10 @@ lipo -create \ "$ROOT/target/x86_64-apple-darwin/release/jst" \ -output "$OUTPUT_DIR/jst" chmod 755 "$OUTPUT_DIR/jst" +"$ROOT/scripts/build-apple-intelligence-helper.sh" "$OUTPUT_DIR/jst-apple-intelligence" cp "$ROOT/LICENSE" "$OUTPUT_DIR/LICENSE" lipo -info "$OUTPUT_DIR/jst" +lipo -info "$OUTPUT_DIR/jst-apple-intelligence" "$OUTPUT_DIR/jst" --version echo "$OUTPUT_DIR" diff --git a/scripts/render-homebrew-formula.sh b/scripts/render-homebrew-formula.sh index 93c679c..8a22abe 100755 --- a/scripts/render-homebrew-formula.sh +++ b/scripts/render-homebrew-formula.sh @@ -33,6 +33,7 @@ class Jst < Formula def install bin.install "jst" + libexec.install "jst-apple-intelligence" end test do diff --git a/scripts/sign-and-notarize-macos.sh b/scripts/sign-and-notarize-macos.sh index 03768fe..f86ab59 100755 --- a/scripts/sign-and-notarize-macos.sh +++ b/scripts/sign-and-notarize-macos.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OUTPUT_DIR="$ROOT/dist/jst-macos-universal" BINARY="$OUTPUT_DIR/jst" +APPLE_HELPER="$OUTPUT_DIR/jst-apple-intelligence" ARCHIVE_NAME="${1:-jst-macos-universal.zip}" ARCHIVE="$ROOT/dist/$ARCHIVE_NAME" CHECKSUM="$ARCHIVE.sha256" @@ -17,6 +18,10 @@ fi echo "error: $BINARY not found — run scripts/build-macos-release.sh first" >&2 exit 1 } +[ -x "$APPLE_HELPER" ] || { + echo "error: $APPLE_HELPER not found — run scripts/build-macos-release.sh first" >&2 + exit 1 +} : "${AC_API_KEY_ID:?AC_API_KEY_ID is required}" : "${AC_API_ISSUER_ID:?AC_API_ISSUER_ID is required}" : "${AC_API_KEY_PATH:?AC_API_KEY_PATH is required}" @@ -31,6 +36,8 @@ if [ -z "${SIGNING_IDENTITY:-}" ]; then fi echo "Signing as: $SIGNING_IDENTITY" +codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$APPLE_HELPER" +codesign --verify --strict --verbose=2 "$APPLE_HELPER" codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$BINARY" codesign --verify --strict --verbose=2 "$BINARY" codesign -dv --verbose=4 "$BINARY" 2>&1 | grep -Fq "TeamIdentifier=$SIGNING_TEAM_ID" @@ -42,7 +49,9 @@ VERIFY_DIR="$(mktemp -d)" trap 'rm -rf -- "$VERIFY_DIR"' EXIT /usr/bin/ditto -x -k "$ARCHIVE" "$VERIFY_DIR" VERIFIED_BINARY="$VERIFY_DIR/jst-macos-universal/jst" +VERIFIED_APPLE_HELPER="$VERIFY_DIR/jst-macos-universal/jst-apple-intelligence" codesign --verify --strict --verbose=2 "$VERIFIED_BINARY" +codesign --verify --strict --verbose=2 "$VERIFIED_APPLE_HELPER" codesign -dv --verbose=4 "$VERIFIED_BINARY" 2>&1 | grep -Fq "TeamIdentifier=$SIGNING_TEAM_ID" "$VERIFIED_BINARY" --version