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
18 changes: 4 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Forge monorepo implementing Phase 0-2 components from `docs/components-deep-dive
- `@forge/adapter-codex`: Codex adapter implementation.
- `@forge/adapter-claude`: Claude adapter implementation.
- `@forge/control-plane`: Plan execution orchestration and evidence.
- `@forge/cli`: Public `forge` CLI commands.
- `@forge/sidecar`: Internal Desktop sidecar process (JSON over stdin/stdout).
- `@forge/desktop`: Tauri + Vue orchestrator UI.

## Modules
Expand All @@ -30,7 +30,6 @@ Forge monorepo implementing Phase 0-2 components from `docs/components-deep-dive
- `@forge/adapter-codex`: `packages/adapter-codex/README.md`
- `@forge/adapter-claude`: `packages/adapter-claude/README.md`
- `@forge/control-plane`: `packages/control-plane/README.md`
- `@forge/cli`: `packages/cli/README.md`
- `@forge/shared-utils`: `packages/shared-utils/README.md`

## Desktop Packs
Expand All @@ -44,18 +43,9 @@ Forge Desktop can download packs (from GitHub Releases) and install a selected p
3. Synchronize workflow assets with `bun run workflow:sync`.
4. Run `bun run verify`.

## Core Commands

- `forge init <project-name>`
- `forge scaffold module <module-name>`
- `forge install-guidance`
- `forge plan validate --file <path>`
- `forge plan migrate --file <path> --write`
- `forge run next --plan <path>`
- `forge run resume --plan <path> --run-id <id>`
- `forge workflow check --plan <path>`
- `forge workflow auto --plan <path> --adapter codex|claude`
- `forge codex session`
## Interface

Forge is Desktop-only: use `@forge/desktop` (Tauri + Vue) as the user interface. The Desktop backend spawns an internal `@forge/sidecar` process for plan validation, guidance install, workflow execution, and Codex sessions.

## Workflow Enforcement

Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ Forge Desktop can download non-executable "packs" (starting with `forge-guidance
Notes:

- Switching pack names is supported. If you switch packs, consider enabling **Force replace local changes** to avoid a mixed configuration.
- Installing into a project runs `forge install-guidance` via the Desktop backend.
- Installing into a project runs the internal Forge sidecar via the Desktop backend.

Configuration (optional):

- `FORGE_DESKTOP_PACKS_REPO`: GitHub repo in `owner/repo` form that hosts release assets (default: `forge/forge`).
- `FORGE_DESKTOP_FORGE_BIN`: Path to a `forge` executable to run from the desktop backend.
- `FORGE_DESKTOP_FORGE_ENTRY_JS`: Path to `packages/cli/dist/bin.js` (desktop will run `node <entry>`).
- `FORGE_DESKTOP_SIDECAR_BIN`: Path to a sidecar executable to run from the desktop backend.
- `FORGE_DESKTOP_SIDECAR_ENTRY_JS`: Path to `packages/sidecar/dist/entry.js` (desktop will run `node <entry>`).

Expected release assets:

Expand Down
110 changes: 0 additions & 110 deletions apps/desktop/src-tauri/src/forge_cli.rs

This file was deleted.

124 changes: 124 additions & 0 deletions apps/desktop/src-tauri/src/forge_sidecar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;

use tauri::{AppHandle, Manager};

fn resolve_sidecar_bin_from_resources(app: &AppHandle) -> Option<PathBuf> {
let resource_dir = app.path().resource_dir().ok()?;
let candidates = vec![
resource_dir.join("sidecar").join("entry.js"),
resource_dir.join("sidecar-entry.js"),
resource_dir.join("entry.js"),
];
candidates.into_iter().find(|path| path.exists())
}

fn resolve_sidecar_command(app: &AppHandle) -> Result<(PathBuf, Vec<String>), String> {
if let Ok(value) = std::env::var("FORGE_DESKTOP_SIDECAR_BIN") {
return Ok((PathBuf::from(value), vec![]));
}

if let Ok(entry) = std::env::var("FORGE_DESKTOP_SIDECAR_ENTRY_JS") {
let node = std::env::var("FORGE_DESKTOP_NODE_BIN").unwrap_or_else(|_| "node".to_string());
return Ok((PathBuf::from(node), vec![entry]));
}

if let Some(entry) = resolve_sidecar_bin_from_resources(app) {
let node = std::env::var("FORGE_DESKTOP_NODE_BIN").unwrap_or_else(|_| "node".to_string());
return Ok((PathBuf::from(node), vec![entry.to_string_lossy().to_string()]));
}

Err("Unable to resolve Forge sidecar entry. Set FORGE_DESKTOP_SIDECAR_ENTRY_JS.".to_string())
}

fn augmented_path() -> String {
let current = std::env::var("PATH").unwrap_or_default();
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
// GUI apps on macOS don't inherit the user's shell PATH.
// Prepend common tool directories so gate scripts can find bun, cargo, etc.
let extras = [
format!("{home}/.bun/bin"),
format!("{home}/.cargo/bin"),
format!("{home}/.local/bin"),
"/usr/local/bin".to_string(),
];
let mut parts: Vec<String> = extras
.into_iter()
.filter(|p| std::path::Path::new(p).is_dir())
.collect();
if !current.is_empty() {
parts.push(current);
}
parts.join(":")
}

pub fn run_sidecar_json(
app: &AppHandle,
cwd: &Path,
request: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let (bin, base_args) = resolve_sidecar_command(app)?;
let mut child = Command::new(&bin)
.current_dir(cwd)
.env("PATH", augmented_path())
.args(base_args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|error| format!("failed to start sidecar command {bin:?}: {error}"))?;

let line = format!("{}\n", request);
child
.stdin
.as_mut()
.ok_or_else(|| "child stdin missing".to_string())?
.write_all(line.as_bytes())
.map_err(|error| format!("failed to write to sidecar stdin: {error}"))?;

let output = child
.wait_with_output()
.map_err(|error| format!("failed to wait for sidecar: {error}"))?;

let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();

if !output.status.success() {
// Sidecar uses exit code 2 for structured failures where stdout still contains valid JSON.
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&stdout) {
return Ok(value);
}
return Err(format!(
"sidecar command failed (status={:?}). stderr: {}",
output.status.code(),
stderr.trim()
));
}

serde_json::from_str::<serde_json::Value>(&stdout)
.map_err(|error| format!("sidecar did not return valid JSON: {error}. stdout: {}", stdout.trim()))
}

pub fn spawn_sidecar_stream_with_env(
app: &AppHandle,
cwd: &Path,
extra_env: &[(&str, &str)],
) -> Result<tokio::process::Child, String> {
let (bin, base_args) = resolve_sidecar_command(app)?;
let mut cmd = tokio::process::Command::new(&bin);
cmd.current_dir(cwd)
.env("PATH", augmented_path())
.args(base_args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());

for (k, v) in extra_env {
cmd.env(k, v);
}

cmd.spawn()
.map_err(|error| format!("failed to spawn sidecar command {bin:?}: {error}"))
}

Loading