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: 2 additions & 0 deletions docs/EDITORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ configure by hand:
wally claude-code -m qwen3-4b-instruct-2507
wally codex -m qwen3-4b-instruct-2507
wally chatgpt -m qwen3-4b-instruct-2507
wally openwork -m qwen3-4b-instruct-2507
wally hermes -m qwen3-4b-instruct-2507
wally deepseek -m qwen3-4b-instruct-2507
wally openclaw -m qwen3-4b-instruct-2507
Expand All @@ -24,6 +25,7 @@ tool starts the way you already have it configured, and wally wires nothing.
| `claude-code` | `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` in the process |
| `codex` | `-c` overrides on its command line naming a `wally` provider at the translator, plus a model catalog file for the run; the loopback token is in `WALLY_CODEX_API_KEY`. It runs in a Codex home of wally's own, so its threads and settings stay apart from yours; nothing is copied in and `~/.codex` is never written |
| `chatgpt` | the ChatGPT app for macOS started as a second instance with its own app data and its own Codex home, whose `config.toml` holds the same settings `codex` passes as `-c` (one block wally rewrites per launch; the app's own entries are kept) and an API-key `auth.json` so no ChatGPT sign-in is asked for. Your own ChatGPT app and `~/.codex` are never touched. Quitting the app ends the run, and Ctrl-C quits the app |
| `openwork` | the OpenWork desktop app (public edition, `brew install --cask openwork` on macOS) started as a second instance on a profile of wally's own under the state directory (`openwork/`): its Electron data, runtime database, server settings, opencode history and a `RunAnywhere` workspace all live there, so your own OpenWork, your opencode history and `~/OpenWork Chat` are never written. Its opencode 1.x engine gets the provider in `OPENCODE_CONFIG_CONTENT`: the hosted catalog under `runanywhere` and, for a local model, `runanywhere-local` beside it, with the launched model selected. Chats stay on that engine (`OPENWORK_ENGINE_V2_PREVIEW=off`, `OPENWORK_ENGINE_AUTO_UPGRADE=0`), because OpenWork starts its opencode 2 engine with an allowlisted environment that never sees the provider. Your global `~/.config/opencode` is still read, but opencode's log and saved logins go to the profile too (`XDG_DATA_HOME`). The app's console output goes to `openwork/openwork.log`. The profile is kept between runs. Quitting the app ends the run, and Ctrl-C quits the app. On Linux only `OpenWork.AppImage` on PATH is found, never a bare `openwork` (the npm package of that name is another program); `WALLY_OPENWORK_APP` points at any other install. The Windows location (`%LOCALAPPDATA%\Programs\OpenWork\OpenWork.exe`) has not been checked against a real install yet |
| `opencode` | a temporary provider in `OPENCODE_CONFIG_CONTENT`, with the local or hosted endpoint and context limits |
| `claude-desktop` | a gateway profile in Claude Desktop's third party mode, covering the chat and Cowork tabs |
| `hermes` | `CUSTOM_BASE_URL`, `HERMES_INFERENCE_PROVIDER=custom`, the model in `HERMES_INFERENCE_MODEL`, and the key under the name its host gates on |
Expand Down
131 changes: 131 additions & 0 deletions scripts/test/e2e-openwork.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Real-app round trip for `wally openwork` on macOS: launches the installed
# OpenWork on wally's private profile, asks its engine which models it offers,
# sends one prompt per provider, quits the app and checks nothing is left
# running. Opt-in: needs WALLY_E2E_OPENWORK=1, OpenWork.app, and a model
# (WALLY_E2E_OPENWORK_MODEL, a local id or a hosted one on a signed-in
# account). WALLY_E2E_OPENWORK_HOSTED names a hosted model to prompt as well.
#
# WALLY_E2E_OPENWORK=1 WALLY_E2E_OPENWORK_MODEL=qwen3-4b-instruct-2507 \
# bash scripts/test/e2e-openwork.sh ./build/wally
set -euo pipefail

WALLY="${1:?usage: e2e-openwork.sh <wally binary>}"
if [[ "${WALLY_E2E_OPENWORK:-0}" != "1" ]]; then
echo " skip openwork (WALLY_E2E_OPENWORK is not 1)"
exit 0
fi
if [[ "$(uname -s)" != "Darwin" ]]; then
echo " skip openwork (macOS only)"
exit 0
fi
MODEL="${WALLY_E2E_OPENWORK_MODEL:?set WALLY_E2E_OPENWORK_MODEL}"
HOSTED="${WALLY_E2E_OPENWORK_HOSTED:-}"
STATE="${XDG_STATE_HOME:-${HOME}/.local/state}/runanywhere/openwork"
ENGINES="${STATE}/config/engine-instances.json"
LOG="$(mktemp -t wally-openwork-e2e)"

if pgrep -f "OpenWork.app/Contents/MacOS/OpenWork" >/dev/null; then
echo " FAIL openwork: quit every OpenWork window first, so the instance this run starts can be told apart"
exit 1
fi

# Only a folder this run creates counts against it.
HOME_WORKSPACE_EXISTED=0
[[ -e "${HOME}/OpenWork Chat" ]] && HOME_WORKSPACE_EXISTED=1

rm -f "${ENGINES}"
"${WALLY}" openwork -m "${MODEL}" >"${LOG}" 2>&1 &
WALLY_PID=$!
cleanup() {
osascript -e 'tell application "OpenWork" to quit' >/dev/null 2>&1 || true
for _ in $(seq 1 30); do
kill -0 "${WALLY_PID}" 2>/dev/null || return 0
sleep 1
done
echo " FAIL openwork: wally was still running 30 s after the app was asked to quit"
kill "${WALLY_PID}" 2>/dev/null || true
return 1
}
trap cleanup EXIT

for _ in $(seq 1 120); do
[[ -s "${ENGINES}" ]] && break
sleep 1
done
if [[ ! -s "${ENGINES}" ]]; then
echo " FAIL openwork: the engine never started"
cat "${LOG}"
exit 1
fi

python3 - "${ENGINES}" "${STATE}/workspace" "${MODEL}" "${HOSTED}" <<'PY'
import json, sys, time, urllib.parse, urllib.request

engines, workspace, model, hosted = sys.argv[1:5]
# The file is written before the engine is registered in it.
for _ in range(120):
entries = json.load(open(engines)).get("entries") or []
if entries:
break
time.sleep(1)
assert entries, "the engine never registered"
entry = entries[0]
base, auth = entry["url"], entry["authProbe"]
query = "?directory=" + urllib.parse.quote(workspace)

def call(path, body=None, timeout=30):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(base + path + query, data=data,
headers={"Authorization": auth, "Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.load(response)

for _ in range(60):
try:
providers = call("/config/providers")
break
except OSError:
time.sleep(1)
by_id = {p["id"]: p for p in providers["providers"]}
connected = call("/provider")["connected"]
selected = call("/config").get("model", "")
ours = [pid for pid in ("runanywhere", "runanywhere-local") if pid in by_id]
assert ours, f"no RunAnywhere provider in {sorted(by_id)}"
for pid in ours:
assert pid in connected, f"{pid} is not connected: {connected}"
print(f" ok openwork lists {len(by_id[pid]['models'])} models under {pid}")
assert selected.endswith("/" + model), f"selected {selected!r}, launched {model!r}"
print(f" ok openwork selects {selected}")

targets = [(selected.split("/", 1)[0], model)]
if hosted:
targets.append(("runanywhere", hosted))
for provider, model_id in targets:
session = call("/session", {})["id"]
reply = call(f"/session/{session}/message", {
"model": {"providerID": provider, "modelID": model_id},
"parts": [{"type": "text", "text": "Reply with exactly the word pong and nothing else."}],
}, timeout=300)
error = reply.get("info", {}).get("error")
text = " ".join(p.get("text", "") for p in reply.get("parts", []) if p.get("type") == "text")
assert not error and text.strip().strip(".").lower() == "pong", f"{provider}/{model_id}: error={error} text={text!r}"
print(f" ok openwork answered on {provider}/{model_id}: {text.strip()[:40]!r}")
PY

trap - EXIT
cleanup || exit 1
# The engine is stopped by the app on its way out, which takes a few seconds.
for _ in $(seq 1 20); do
pgrep -f "OpenWork.app/Contents/Resources/sidecars/opencode" >/dev/null || break
sleep 1
done
if pgrep -f "OpenWork.app/Contents/Resources/sidecars/opencode" >/dev/null; then
echo " FAIL openwork: its engine is still running after the app quit"
exit 1
fi
if [[ "${HOME_WORKSPACE_EXISTED}" == 0 && -e "${HOME}/OpenWork Chat" ]]; then
echo " FAIL openwork: the default workspace landed in the real home"
exit 1
fi
echo " ok openwork quit cleanly"
8 changes: 8 additions & 0 deletions scripts/test/e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,12 @@ else
echo " skip modalities (WALLY_E2E_MODALITIES=0)"
fi

# The OpenWork desktop app on wally's private profile; opt-in, macOS only.
if bash "${ROOT}/scripts/test/e2e-openwork.sh" "${WALLY}"; then
:
else
echo " FAIL openwork"
fail=1
fi

exit "${fail}"
5 changes: 5 additions & 0 deletions skills/runanywhere/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ If opencode is not installed, `wally` says so and prints the install command
(`npm i -g opencode-ai`) rather than failing. Install it, then run the same
line again.

`wally openwork -m <model>` opens the OpenWork desktop app on a private profile,
with your account's hosted models in its model picker and, for a local `-m`, that
local model beside them. On macOS it offers `brew install --cask openwork` when
the app is missing. The app flow has been checked on macOS only.

Offer to explain a harness before running it. Someone who has just signed up
does not yet know what opencode is, and "would you like me to explain how the
opencode harness works?" is a better second message than a launched TUI.
Expand Down
4 changes: 3 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ pub fn configure_app(app: &mut App) {
crate::commands::register_editors(app);
crate::commands::register_codex(app);
crate::commands::register_chatgpt(app);
crate::commands::register_openwork(app);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
crate::commands::register_harness(app); // coding agents
crate::commands::register_default_models(app);

Expand Down Expand Up @@ -129,6 +130,7 @@ pub fn configure_app(app: &mut App) {
"claude-desktop",
"codex",
"chatgpt",
"openwork",
"hermes",
"openclaw",
"deepseek",
Expand Down Expand Up @@ -193,7 +195,7 @@ fn is_passthrough_command(token: &str) -> bool {
/// the help page except `update` (it is the update) and `uninstall` (there is
/// nothing left to update). Help pages show it too, from run().
fn shows_update_notice(path: &[String]) -> bool {
const NAMES: &[&str] = &["run", "serve", "models", "account", "about"];
const NAMES: &[&str] = &["run", "serve", "models", "account", "about", "openwork"];
path.first()
.is_some_and(|name| NAMES.contains(&name.as_str()) || is_passthrough_command(name))
}
Expand Down
5 changes: 5 additions & 0 deletions src/cli_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,11 @@ mod tests {
"Open ChatGPT's Codex with a model",
"Coding tools",
),
leaf(
"openwork",
"Open OpenWork with a local or cloud model",
"Coding tools",
),
leaf(
"opencode",
"Open OpenCode with a local or cloud model",
Expand Down
Loading
Loading