mirror of
https://github.com/different-ai/openwork
synced 2026-04-26 01:25:10 +02:00
refactor: repo folder structure (#1038)
* refactor(repo): move OpenWork apps into apps and ee layout Rebase the monorepo layout migration onto the latest dev changes so the moved app, desktop, share, and cloud surfaces keep working from their new paths. Carry the latest deeplink, token persistence, build, Vercel, and docs updates forward to avoid stale references and broken deploy tooling. * chore(repo): drop generated desktop artifacts Ignore the moved Tauri target and sidecar paths so local cargo checks do not pollute the branch. Remove the accidentally committed outputs from the repo while keeping the layout migration intact. * fix(release): drop built server cli artifact Stop tracking the locally built apps/server/cli binary so generated server outputs do not leak into commits. Also update the release workflow to check the published scoped package name for @openwork/server before deciding whether npm publish is needed. * fix(workspace): add stable CLI bin wrappers Point the server and router package bins at committed wrapper scripts so workspace installs can create shims before dist outputs exist. Keep the wrappers compatible with built binaries and source checkouts to avoid Vercel install warnings without changing runtime behavior.
This commit is contained in:
555
apps/desktop/src-tauri/src/workspace/files.rs
Normal file
555
apps/desktop/src-tauri/src/workspace/files.rs
Normal file
@@ -0,0 +1,555 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use crate::types::{OpencodeCommand, WorkspaceOpenworkConfig};
|
||||
use crate::utils::now_ms;
|
||||
use crate::workspace::commands::{sanitize_command_name, serialize_command_frontmatter};
|
||||
|
||||
pub fn merge_plugins(existing: Vec<String>, required: &[&str]) -> Vec<String> {
|
||||
let mut out = existing;
|
||||
for plugin in required {
|
||||
if !out.iter().any(|entry| entry == plugin) {
|
||||
out.push(plugin.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn seed_workspace_guide(skill_root: &PathBuf) -> Result<(), String> {
|
||||
let guide_dir = skill_root.join("workspace-guide");
|
||||
if guide_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fs::create_dir_all(&guide_dir)
|
||||
.map_err(|e| format!("Failed to create {}: {e}", guide_dir.display()))?;
|
||||
|
||||
let doc = r#"---
|
||||
name: workspace-guide
|
||||
description: Workspace guide to introduce OpenWork and onboard new users.
|
||||
---
|
||||
|
||||
# Welcome to OpenWork
|
||||
|
||||
Hi, I'm Ben and this is OpenWork. It's an open-source alternative to Claude's cowork. It helps you work on your files with AI and automate the mundane tasks so you don't have to.
|
||||
|
||||
Before we start, use the question tool to ask:
|
||||
"Are you more technical or non-technical? I'll tailor the explanation."
|
||||
|
||||
## If the person is non-technical
|
||||
OpenWork feels like a chat app, but it can safely work with the files you allow. Put files in this workspace and I can summarize them, create new ones, or help organize them.
|
||||
|
||||
Try:
|
||||
- "Summarize the files in this workspace."
|
||||
- "Create a checklist for my week."
|
||||
- "Draft a short summary from this document."
|
||||
|
||||
## Skills and plugins (simple)
|
||||
Skills add new capabilities. Plugins add advanced features like scheduling or browser automation. We can add them later when you're ready.
|
||||
|
||||
## If the person is technical
|
||||
OpenWork is a GUI for OpenCode. Everything that works in OpenCode works here.
|
||||
|
||||
Most reliable setup today:
|
||||
1) Install OpenCode from opencode.ai
|
||||
2) Configure providers there (models and API keys)
|
||||
3) Come back to OpenWork and start a session
|
||||
|
||||
Skills:
|
||||
- Install from the Skills tab, or add them to this workspace.
|
||||
- Docs: https://opencode.ai/docs/skills
|
||||
|
||||
Plugins:
|
||||
- Configure in opencode.json or use the Plugins tab.
|
||||
- Docs: https://opencode.ai/docs/plugins/
|
||||
|
||||
MCP servers:
|
||||
- Add external tools via opencode.json.
|
||||
- Docs: https://opencode.ai/docs/mcp-servers/
|
||||
|
||||
Config reference:
|
||||
- Docs: https://opencode.ai/docs/config/
|
||||
|
||||
End with two friendly next actions to try in OpenWork."#;
|
||||
|
||||
fs::write(guide_dir.join("SKILL.md"), doc)
|
||||
.map_err(|e| format!("Failed to write SKILL.md: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seed_get_started_skill(skill_root: &PathBuf) -> Result<(), String> {
|
||||
let skill_dir = skill_root.join("get-started");
|
||||
if skill_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fs::create_dir_all(&skill_dir)
|
||||
.map_err(|e| format!("Failed to create {}: {e}", skill_dir.display()))?;
|
||||
|
||||
let doc = r#"---
|
||||
name: get-started
|
||||
description: Guide users through the get started setup and Chrome DevTools demo.
|
||||
---
|
||||
|
||||
## When to use
|
||||
- Always load this skill when the user says \"get started\".
|
||||
|
||||
## What to do
|
||||
- Reply with these four lines, exactly and in order:
|
||||
1) hey there welcome this is openwork
|
||||
2) we've pre-configured you with a couple tools
|
||||
3) Get Started
|
||||
4) write \"hey go on google.com\"
|
||||
|
||||
## Then
|
||||
- If the user writes \"go on google.com\" (or \"hey go on google.com\"), use the chrome-devtools MCP to open the site.
|
||||
- After the navigation completes, reply: \"I'm on <site>\" where <site> is the final URL or page title they asked for.
|
||||
"#;
|
||||
|
||||
fs::write(skill_dir.join("SKILL.md"), doc)
|
||||
.map_err(|e| format!("Failed to write SKILL.md: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seed_openwork_agent(agent_root: &PathBuf) -> Result<(), String> {
|
||||
let agent_path = agent_root.join("openwork.md");
|
||||
if agent_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fs::create_dir_all(agent_root)
|
||||
.map_err(|e| format!("Failed to create {}: {e}", agent_root.display()))?;
|
||||
|
||||
let doc = r#"---
|
||||
description: OpenWork default agent (safe, mobile-first, self-referential)
|
||||
mode: primary
|
||||
temperature: 0.2
|
||||
---
|
||||
|
||||
You are OpenWork.
|
||||
|
||||
When the user refers to \"you\", they mean the OpenWork app and the current workspace.
|
||||
|
||||
Your job:
|
||||
- Help the user work on files safely.
|
||||
- Automate repeatable work.
|
||||
- Keep behavior portable and reproducible.
|
||||
|
||||
Memory (two kinds)
|
||||
1) Behavior memory (shareable, in git)
|
||||
- `.opencode/skills/**`
|
||||
- `.opencode/agents/**`
|
||||
- repo docs
|
||||
|
||||
2) Private memory (never commit)
|
||||
- Tokens, IDs, credentials
|
||||
- Local DBs/logs/config files (gitignored)
|
||||
- Notion pages/databases (if configured via MCP)
|
||||
|
||||
Hard rule: never copy private memory into repo files verbatim. Store only redacted summaries, schemas/templates, and stable pointers.
|
||||
|
||||
Reconstruction-first
|
||||
- Do not assume env vars or prior setup.
|
||||
- If required state is missing, ask one targeted question.
|
||||
- After the user provides it, store it in private memory and continue.
|
||||
|
||||
Verification-first
|
||||
- If you change code, run the smallest meaningful test or smoke check.
|
||||
- If you touch UI or remote behavior, validate end-to-end and capture logs on failure.
|
||||
|
||||
Incremental adoption loop
|
||||
- Do the task once end-to-end.
|
||||
- If steps repeat, factor them into a skill.
|
||||
- If the work becomes ongoing, create/refine an agent role.
|
||||
- If it should run regularly, schedule it and store outputs in private memory.
|
||||
|
||||
Specific User Requests
|
||||
- If a user asks you to do something with a broswer, like 'open a new tab', check if you have access to the chrome-devtools-mcp - if not, then ask the user to add the 'Control Chrome' extension using the sidebar or via the worker settings.
|
||||
"#;
|
||||
|
||||
fs::write(&agent_path, doc)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", agent_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const ENTERPRISE_ARCHIVE_URL: &str =
|
||||
"https://github.com/different-ai/openwork-enterprise/archive/refs/heads/main.zip";
|
||||
const ENTERPRISE_SEED_MARKER: &str = ".openwork-enterprise-creators";
|
||||
static ENTERPRISE_SEED_IN_FLIGHT: LazyLock<Mutex<HashSet<String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
|
||||
fn enterprise_seed_marker_path(root: &Path) -> PathBuf {
|
||||
root.join(".opencode").join(ENTERPRISE_SEED_MARKER)
|
||||
}
|
||||
|
||||
fn spawn_enterprise_creator_skills_seed(root: PathBuf, skill_root: PathBuf) {
|
||||
let marker_path = enterprise_seed_marker_path(&root);
|
||||
if marker_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let key = root.to_string_lossy().to_string();
|
||||
{
|
||||
let mut in_flight = ENTERPRISE_SEED_IN_FLIGHT
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !in_flight.insert(key.clone()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::spawn(move || {
|
||||
println!(
|
||||
"[workspace] Seeding creator skills in background for {}",
|
||||
root.display()
|
||||
);
|
||||
|
||||
let result = seed_enterprise_creator_skills(&root, &skill_root);
|
||||
match result {
|
||||
Ok(()) => println!(
|
||||
"[workspace] Finished seeding creator skills for {}",
|
||||
root.display()
|
||||
),
|
||||
Err(err) => println!(
|
||||
"[workspace] Failed to seed creator skills in background for {}: {err}",
|
||||
root.display()
|
||||
),
|
||||
}
|
||||
|
||||
let mut in_flight = ENTERPRISE_SEED_IN_FLIGHT
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
in_flight.remove(&key);
|
||||
});
|
||||
}
|
||||
|
||||
fn seed_enterprise_creator_skills(root: &PathBuf, skill_root: &PathBuf) -> Result<(), String> {
|
||||
let marker_path = enterprise_seed_marker_path(root);
|
||||
if marker_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut existing = HashSet::new();
|
||||
if let Ok(entries) = fs::read_dir(skill_root) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.is_empty() {
|
||||
existing.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.redirects(5)
|
||||
.timeout_connect(Duration::from_secs(5))
|
||||
.timeout_read(Duration::from_secs(20))
|
||||
.build();
|
||||
let response = agent
|
||||
.get(ENTERPRISE_ARCHIVE_URL)
|
||||
.call()
|
||||
.map_err(|e| format!("Failed to download enterprise archive: {e}"))?;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.read_to_end(&mut buffer)
|
||||
.map_err(|e| format!("Failed to read enterprise archive: {e}"))?;
|
||||
|
||||
let cursor = Cursor::new(buffer);
|
||||
let mut archive =
|
||||
ZipArchive::new(cursor).map_err(|e| format!("Failed to open enterprise archive: {e}"))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read enterprise entry: {e}"))?;
|
||||
let name = entry.name().to_string();
|
||||
let entry_path = Path::new(&name);
|
||||
if entry_path.components().any(|component| match component {
|
||||
std::path::Component::ParentDir
|
||||
| std::path::Component::RootDir
|
||||
| std::path::Component::Prefix(_) => true,
|
||||
_ => false,
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<String> = entry_path
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.collect();
|
||||
if parts.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
if parts[1] != ".opencode" || parts[2] != "skills" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let skill_name = &parts[3];
|
||||
if !skill_name.ends_with("-creator") {
|
||||
continue;
|
||||
}
|
||||
if existing.contains(skill_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dest_root = skill_root.join(skill_name);
|
||||
let mut dest_path = dest_root.clone();
|
||||
for part in parts.iter().skip(4) {
|
||||
dest_path = dest_path.join(part);
|
||||
}
|
||||
|
||||
if name.ends_with('/') {
|
||||
fs::create_dir_all(&dest_path)
|
||||
.map_err(|e| format!("Failed to create {}: {e}", dest_path.display()))?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut file_buffer = Vec::new();
|
||||
entry
|
||||
.read_to_end(&mut file_buffer)
|
||||
.map_err(|e| format!("Failed to read enterprise entry: {e}"))?;
|
||||
fs::write(&dest_path, file_buffer)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", dest_path.display()))?;
|
||||
}
|
||||
|
||||
fs::write(&marker_path, "seeded\n")
|
||||
.map_err(|e| format!("Failed to write {}: {e}", marker_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seed_commands(commands_dir: &PathBuf, preset: &str) -> Result<(), String> {
|
||||
if fs::read_dir(commands_dir)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", commands_dir.display()))?
|
||||
.next()
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let defaults = vec![
|
||||
OpencodeCommand {
|
||||
name: "learn-files".to_string(),
|
||||
description: Some("Safe, practical file workflows".to_string()),
|
||||
template: "Show me how to interact with files in this workspace. Include safe examples for reading, summarizing, and editing.".to_string(),
|
||||
agent: None,
|
||||
model: None,
|
||||
subtask: None,
|
||||
},
|
||||
OpencodeCommand {
|
||||
name: "learn-skills".to_string(),
|
||||
description: Some("How skills work and how to create your own".to_string()),
|
||||
template: "Explain what skills are, how to use them, and how to create a new skill for this workspace.".to_string(),
|
||||
agent: None,
|
||||
model: None,
|
||||
subtask: None,
|
||||
},
|
||||
OpencodeCommand {
|
||||
name: "learn-plugins".to_string(),
|
||||
description: Some("What plugins are and how to install them".to_string()),
|
||||
template: "Explain what plugins are and how to install them in this workspace.".to_string(),
|
||||
agent: None,
|
||||
model: None,
|
||||
subtask: None,
|
||||
},
|
||||
];
|
||||
|
||||
let mut defaults = defaults;
|
||||
if preset == "starter" {
|
||||
defaults.push(OpencodeCommand {
|
||||
name: "Get Started".to_string(),
|
||||
description: Some("Get started".to_string()),
|
||||
template: "get started".to_string(),
|
||||
agent: None,
|
||||
model: None,
|
||||
subtask: None,
|
||||
});
|
||||
}
|
||||
|
||||
for command in defaults {
|
||||
let Some(name) = sanitize_command_name(&command.name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let file_path = commands_dir.join(format!("{name}.md"));
|
||||
if file_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let serialized = serialize_command_frontmatter(&command)?;
|
||||
fs::write(&file_path, serialized)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", file_path.display()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_workspace_files(workspace_path: &str, preset: &str) -> Result<(), String> {
|
||||
let root = PathBuf::from(workspace_path);
|
||||
|
||||
let skill_root = root.join(".opencode").join("skills");
|
||||
fs::create_dir_all(&skill_root)
|
||||
.map_err(|e| format!("Failed to create .opencode/skills: {e}"))?;
|
||||
seed_workspace_guide(&skill_root)?;
|
||||
if preset == "starter" {
|
||||
seed_get_started_skill(&skill_root)?;
|
||||
spawn_enterprise_creator_skills_seed(root.clone(), skill_root.clone());
|
||||
}
|
||||
|
||||
let agents_dir = root.join(".opencode").join("agents");
|
||||
fs::create_dir_all(&agents_dir)
|
||||
.map_err(|e| format!("Failed to create .opencode/agents: {e}"))?;
|
||||
seed_openwork_agent(&agents_dir)?;
|
||||
|
||||
let commands_dir = root.join(".opencode").join("commands");
|
||||
fs::create_dir_all(&commands_dir)
|
||||
.map_err(|e| format!("Failed to create .opencode/commands: {e}"))?;
|
||||
seed_commands(&commands_dir, preset)?;
|
||||
|
||||
let config_path_jsonc = root.join("opencode.jsonc");
|
||||
let config_path_json = root.join("opencode.json");
|
||||
let config_path = if config_path_jsonc.exists() {
|
||||
config_path_jsonc
|
||||
} else if config_path_json.exists() {
|
||||
config_path_json
|
||||
} else {
|
||||
config_path_jsonc
|
||||
};
|
||||
|
||||
let config_exists = config_path.exists();
|
||||
let mut config_changed = !config_exists;
|
||||
let mut config: serde_json::Value = if config_exists {
|
||||
let raw = fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?;
|
||||
json5::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
})
|
||||
};
|
||||
|
||||
if !config.is_object() {
|
||||
config = serde_json::json!({
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
});
|
||||
config_changed = true;
|
||||
}
|
||||
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
let current = obj
|
||||
.get("default_agent")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if current.is_empty() {
|
||||
obj.insert(
|
||||
"default_agent".to_string(),
|
||||
serde_json::Value::String("openwork".to_string()),
|
||||
);
|
||||
config_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let required_plugins: Vec<&str> = match preset {
|
||||
"starter" => vec!["opencode-scheduler"],
|
||||
"automation" => vec!["opencode-scheduler"],
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
let should_seed_chrome_mcp = matches!(preset, "starter");
|
||||
|
||||
if !required_plugins.is_empty() {
|
||||
let plugins_value = config
|
||||
.get("plugin")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!([]));
|
||||
|
||||
let existing_plugins: Vec<String> = match plugins_value {
|
||||
serde_json::Value::Array(arr) => arr
|
||||
.into_iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect(),
|
||||
serde_json::Value::String(s) => vec![s],
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
let merged = merge_plugins(existing_plugins.clone(), &required_plugins);
|
||||
if merged != existing_plugins {
|
||||
config_changed = true;
|
||||
}
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
obj.insert(
|
||||
"plugin".to_string(),
|
||||
serde_json::Value::Array(
|
||||
merged.into_iter().map(serde_json::Value::String).collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if should_seed_chrome_mcp {
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
let mcp_value = obj
|
||||
.get("mcp")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let mut mcp_obj = match mcp_value {
|
||||
serde_json::Value::Object(map) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
|
||||
if !mcp_obj.contains_key("chrome-devtools") {
|
||||
mcp_obj.insert(
|
||||
"chrome-devtools".to_string(),
|
||||
serde_json::json!({
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "chrome-devtools-mcp@latest"]
|
||||
}),
|
||||
);
|
||||
config_changed = true;
|
||||
}
|
||||
|
||||
obj.insert("mcp".to_string(), serde_json::Value::Object(mcp_obj));
|
||||
}
|
||||
}
|
||||
|
||||
if config_changed {
|
||||
fs::write(
|
||||
&config_path,
|
||||
serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", config_path.display()))?;
|
||||
}
|
||||
|
||||
let openwork_path = root.join(".opencode").join("openwork.json");
|
||||
if !openwork_path.exists() {
|
||||
let openwork = WorkspaceOpenworkConfig::new(workspace_path, preset, now_ms());
|
||||
|
||||
fs::create_dir_all(openwork_path.parent().unwrap())
|
||||
.map_err(|e| format!("Failed to create {}: {e}", openwork_path.display()))?;
|
||||
|
||||
fs::write(
|
||||
&openwork_path,
|
||||
serde_json::to_string_pretty(&openwork).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user