Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .opencode/skill/opencode-mirror/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ git -C vendor/opencode pull --ff-only

### Clone mirror
```bash
git clone https://github.com/opencode-ai/opencode vendor/opencode
git clone https://github.com/anomalyco/opencode vendor/opencode
```
4 changes: 2 additions & 2 deletions .opencode/skill/openwork-core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ opencode -p "your prompt" -f json -q

### Clone the OpenCode mirror
```bash
git clone https://github.com/opencode-ai/opencode vendor/opencode
git clone https://github.com/anomalyco/opencode vendor/opencode
```

### Initialize Tauri project
Expand All @@ -81,5 +81,5 @@ pnpm tauri android init

### Clone the OpenCode mirror
```bash
git clone https://github.com/opencode-ai/opencode vendor/opencode
git clone https://github.com/anomalyco/opencode vendor/opencode
```
8 changes: 4 additions & 4 deletions prd-opencode-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ Add a Tauri command to check engine availability and return structured info:

Candidate URLs:

- `https://opencode.ai/install` (if this is the canonical stable redirect)
- `https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install` (as documented upstream)
- `https://opencode.ai/install` (canonical stable installer URL)
- `https://raw.githubusercontent.com/anomalyco/opencode/dev/install` (direct script fallback; matches upstream `dev` branch)

Execution strategy (conceptual):

Expand Down Expand Up @@ -183,9 +183,9 @@ OpenWork should show OS-specific instructions with copy buttons.

Example:

- macOS (Homebrew): `brew install opencode-ai/tap/opencode`
- macOS/Linux (Homebrew, recommended): `brew install anomalyco/tap/opencode`
- macOS/Linux (script): `curl -fsSL https://opencode.ai/install | bash`
- Linux (script fallback): `curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | bash`
- macOS/Linux (script fallback): `curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/dev/install | bash`

## Acceptance Criteria

Expand Down
190 changes: 189 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{
env,
ffi::OsStr,
fs,
net::TcpListener,
path::{Path, PathBuf},
Expand Down Expand Up @@ -35,6 +36,17 @@ pub struct EngineInfo {
pub pid: Option<u32>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct EngineDoctorResult {
pub found: bool,
pub in_path: bool,
pub resolved_path: Option<String>,
pub version: Option<String>,
pub supports_serve: bool,
pub notes: Vec<String>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ExecResult {
Expand All @@ -58,6 +70,114 @@ fn find_free_port() -> Result<u16, String> {
Ok(port)
}

#[cfg(windows)]
const OPENCODE_EXECUTABLE: &str = "opencode.exe";

#[cfg(not(windows))]
const OPENCODE_EXECUTABLE: &str = "opencode";

fn home_dir() -> Option<PathBuf> {
if let Ok(home) = env::var("HOME") {
if !home.trim().is_empty() {
return Some(PathBuf::from(home));
}
}

if let Ok(profile) = env::var("USERPROFILE") {
if !profile.trim().is_empty() {
return Some(PathBuf::from(profile));
}
}

None
}

fn path_entries() -> Vec<PathBuf> {
let mut entries = Vec::new();
let Some(path) = env::var_os("PATH") else {
return entries;
};

entries.extend(env::split_paths(&path));
entries
}

fn resolve_in_path(name: &str) -> Option<PathBuf> {
for dir in path_entries() {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}

fn candidate_opencode_paths() -> Vec<PathBuf> {
let mut candidates = Vec::new();

if let Some(home) = home_dir() {
candidates.push(home.join(".opencode").join("bin").join(OPENCODE_EXECUTABLE));
}

// Homebrew default paths.
candidates.push(PathBuf::from("/opt/homebrew/bin").join(OPENCODE_EXECUTABLE));
candidates.push(PathBuf::from("/usr/local/bin").join(OPENCODE_EXECUTABLE));

// Common Linux paths.
candidates.push(PathBuf::from("/usr/bin").join(OPENCODE_EXECUTABLE));
candidates.push(PathBuf::from("/usr/local/bin").join(OPENCODE_EXECUTABLE));

candidates
}

fn opencode_version(program: &OsStr) -> Option<String> {
let output = Command::new(program).arg("--version").output().ok()?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();

if !stdout.is_empty() {
return Some(stdout);
}
if !stderr.is_empty() {
return Some(stderr);
}

None
}

fn opencode_supports_serve(program: &OsStr) -> bool {
Command::new(program)
.arg("serve")
.arg("--help")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}

fn resolve_opencode_executable() -> (Option<PathBuf>, bool, Vec<String>) {
let mut notes = Vec::new();

if let Some(path) = resolve_in_path(OPENCODE_EXECUTABLE) {
notes.push(format!("Found in PATH: {}", path.display()));
return (Some(path), true, notes);
}

notes.push("Not found on PATH".to_string());

for candidate in candidate_opencode_paths() {
if candidate.is_file() {
notes.push(format!("Found at {}", candidate.display()));
return (Some(candidate), false, notes);
}

notes.push(format!("Missing: {}", candidate.display()));
}

(None, false, notes)
}

fn run_capture_optional(command: &mut Command) -> Result<Option<ExecResult>, String> {
match command.output() {
Ok(output) => {
Expand Down Expand Up @@ -181,6 +301,64 @@ fn engine_stop(manager: State<EngineManager>) -> EngineInfo {
EngineManager::snapshot_locked(&mut state)
}

#[tauri::command]
fn engine_doctor() -> EngineDoctorResult {
let (resolved, in_path, notes) = resolve_opencode_executable();

let (version, supports_serve) = match resolved.as_ref() {
Some(path) => (
opencode_version(path.as_os_str()),
opencode_supports_serve(path.as_os_str()),
),
None => (None, false),
};

EngineDoctorResult {
found: resolved.is_some(),
in_path,
resolved_path: resolved.map(|path| path.to_string_lossy().to_string()),
version,
supports_serve,
notes,
}
}

#[tauri::command]
fn engine_install() -> Result<ExecResult, String> {
#[cfg(windows)]
{
return Ok(ExecResult {
ok: false,
status: -1,
stdout: String::new(),
stderr: "Guided install is not supported on Windows yet. Install OpenCode via Scoop/Chocolatey or https://opencode.ai/install, then restart OpenWork.".to_string(),
});
}

#[cfg(not(windows))]
{
let install_dir = home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".opencode")
.join("bin");

let output = Command::new("bash")
.arg("-lc")
.arg("curl -fsSL https://opencode.ai/install | bash")
.env("OPENCODE_INSTALL_DIR", install_dir)
.output()
.map_err(|e| format!("Failed to run installer: {e}"))?;

let status = output.status.code().unwrap_or(-1);
Ok(ExecResult {
ok: output.status.success(),
status,
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
}

#[tauri::command]
fn engine_start(manager: State<EngineManager>, project_dir: String) -> Result<EngineInfo, String> {
let project_dir = project_dir.trim().to_string();
Expand All @@ -196,7 +374,15 @@ fn engine_start(manager: State<EngineManager>, project_dir: String) -> Result<En
// Stop any existing engine first.
EngineManager::stop_locked(&mut state);

let mut command = Command::new("opencode");
let (program, _in_path, notes) = resolve_opencode_executable();
let Some(program) = program else {
let notes_text = notes.join("\n");
return Err(format!(
"OpenCode CLI not found.\n\nInstall with:\n- brew install anomalyco/tap/opencode\n- curl -fsSL https://opencode.ai/install | bash\n\nNotes:\n{notes_text}"
));
};

let mut command = Command::new(&program);
command
.arg("serve")
.arg("--hostname")
Expand Down Expand Up @@ -395,6 +581,8 @@ pub fn run() {
engine_start,
engine_stop,
engine_info,
engine_doctor,
engine_install,
opkg_install,
import_skill,
read_opencode_config,
Expand Down
Loading