diff --git a/docs/cowork.md b/docs/cowork.md new file mode 100644 index 0000000..1a8288d --- /dev/null +++ b/docs/cowork.md @@ -0,0 +1,85 @@ +# Cowork — a desktop app for InferRoute, powered by goose + +`ir` routes **Claude Code** through InferRoute. **Cowork** is the equivalent for +everyday, non-terminal work: research, writing, working with files — a +point-and-click app, no terminal needed. + +It's powered by [**goose**](https://github.com/block/goose) (Block / Linux +Foundation, Apache-2.0), an open-source agent with both a **desktop app** and a +**CLI**. We don't fork it — `ir cowork` just wires goose to InferRoute and +launches it. Because the wiring is config-only and re-asserted on every launch, +goose updates can't drift it out of sync. + +## Fastest path + +```bash +ir cowork +``` + +This will: +1. install the goose CLI if it isn't already present (or you can grab the + desktop app from ); +2. point goose at InferRoute — provider `anthropic`, your saved key, and routing + through your on-device recorder daemon if it's running (`ir add recording`), + otherwise straight to the cloud; +3. launch the **desktop app** if it's installed, otherwise the goose CLI. + +You'll also be offered Cowork at the end of `ir setup`. + +| command | what it does | +|---|---| +| `ir cowork` | wire goose to InferRoute and launch it | +| `ir cowork --cli` | force the goose CLI even if the desktop is installed | +| `ir cowork --configure-only` | wire it up, don't launch | +| `ir cowork --model NAME` | pin a model (short alias like `kimi`/`glm`, or a canonical id) | + +## In the app + +When the desktop app opens, pick a model from the list (populated from InferRoute's +`/v1/models`) and start a session. The same `inf_…` key you use with `ir` works +here — Cowork reads it from goose's local secret store, which `ir cowork` writes. + +## What gets configured (and why it's safe) + +`ir cowork` owns a small, stable set of keys and **merges** them into goose's +files without touching your other goose settings: + +- `~/.config/goose/config.yaml` — `GOOSE_PROVIDER: anthropic`, + `GOOSE_MODEL`, `ANTHROPIC_HOST` (your recorder daemon or `api.inferroute.ai`) +- `~/.config/goose/secrets.yaml` (mode 600) — `ANTHROPIC_API_KEY` and a + `x-inferroute-client: cowork` tag so the dashboard can attribute Cowork traffic +- `~/.config/environment.d/99-inferroute-goose.conf` (Linux) — + `GOOSE_DISABLE_KEYRING=true`, so goose uses its file secret store on machines + without a working OS keyring + +These are re-written every time you run `ir cowork`, so they self-heal across +goose updates. + +## Wiring it by hand + +If you'd rather not use `ir cowork`, point any goose install at InferRoute: + +```yaml +# ~/.config/goose/config.yaml +GOOSE_PROVIDER: anthropic +GOOSE_MODEL: moonshotai/Kimi-K2.6-TEE # any model from /v1/models +ANTHROPIC_HOST: https://api.inferroute.ai # or http://localhost:5005 to record locally +``` + +```yaml +# ~/.config/goose/secrets.yaml (chmod 600) +ANTHROPIC_API_KEY: inf_your_key_here +``` + +On Linux, also set `GOOSE_DISABLE_KEYRING=true` (e.g. in +`~/.config/environment.d/`) if your machine has no working keyring. + +## Notes + +- **Updates:** the goose CLI is a pinned binary — update it on your schedule with + `goose update`. The desktop app auto-updates; `ir cowork` re-asserts the config + each launch, so that's handled. +- **Cost display:** goose's in-app cost readout depends on + [block/goose#9719](https://github.com/block/goose/pull/9719); until it merges, + cost may show as "unavailable" even though routing and billing work normally — + see your real spend with `ir status` or on the dashboard. diff --git a/inferroute_cli/cowork.py b/inferroute_cli/cowork.py new file mode 100644 index 0000000..e34375c --- /dev/null +++ b/inferroute_cli/cowork.py @@ -0,0 +1,259 @@ +"""`ir cowork` — InferRoute's everyday-work surface, powered by goose. + +goose (https://github.com/block/goose, Apache-2.0) is an open-source agent with +a desktop app *and* a CLI. `ir cowork` wires it to InferRoute and launches it: + + • provider → anthropic (goose speaks the Anthropic Messages API, like Claude Code) + • routing → the on-device recorder daemon when it's running, else the cloud + • key → your saved inferroute key + • tag → x-inferroute-client: cowork (so the dashboard can attribute it) + +Why this is cheap and stable: + • Config-only — we do NOT fork goose. We own a small set of goose config/secret + keys and re-assert them on every launch, so a goose update can't drift us out + of sync (and goose's CLI is a pinned binary you update with `goose update`). + • goose reads these from files, so the desktop app (launched from the menu) and + the CLI both pick them up. + +The desktop app is the point-and-click way to use InferRoute for everyday work — +research, writing, files — no terminal needed. The CLI is the same engine in the +terminal. +""" +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +import sys +from pathlib import Path + +from . import config + +GOOSE_DIR = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "goose" +CONFIG_FILE = GOOSE_DIR / "config.yaml" +SECRETS_FILE = GOOSE_DIR / "secrets.yaml" +CLIENT_TAG = "cowork" +GOOSE_INSTALL_URL = "https://github.com/block/goose/releases/download/stable/download_cli.sh" +GOOSE_DESKTOP_DOWNLOAD = "https://block.github.io/goose/" + + +# ── small YAML helpers (PyYAML) ────────────────────────────────────────────── +def _load_yaml(path: Path) -> dict: + if not path.exists(): + return {} + try: + import yaml + + data = yaml.safe_load(path.read_text()) or {} + return data if isinstance(data, dict) else {} + except Exception: + # A malformed/locked file shouldn't crash the launcher — start fresh, + # but don't clobber: only merge our keys in _write_merged below. + return {} + + +def _write_merged(path: Path, updates: dict, *, secret: bool) -> None: + """Merge ``updates`` into the YAML at ``path``, preserving the user's other + keys. Secret files are written 0600.""" + import yaml + + data = _load_yaml(path) + data.update(updates) + path.parent.mkdir(parents=True, exist_ok=True) + if secret: + os.chmod(path.parent, stat.S_IRWXU) + path.write_text(yaml.safe_dump(data, default_flow_style=False, sort_keys=False)) + if secret: + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) + + +# ── resolution ─────────────────────────────────────────────────────────────── +def _default_model() -> str: + """A balanced model InferRoute serves. Mirrors the `ir` default agent model.""" + try: + from . import models + + alias = models.get("kimi") + if alias is not None: + return alias.model_id + except Exception: + pass + return "moonshotai/Kimi-K2.6-TEE" + + +def _anthropic_host(creds: config.Credentials) -> str: + """Route through the on-device recorder daemon when it's up (records + tags + the session, then forwards to the cloud), else talk to the cloud directly.""" + try: + from . import launch + + return launch._recording_daemon_url() or creds.api_url + except Exception: + return creds.api_url + + +def _goose_cli() -> str | None: + return shutil.which("goose") or next( + (str(p) for p in [Path.home() / ".local" / "bin" / "goose"] if p.exists()), None + ) + + +def _goose_desktop() -> str | None: + """Path to the goose Desktop binary for this platform, or None.""" + home = Path.home() + candidates: list[Path] = [] + if sys.platform == "darwin": + candidates = [Path("/Applications/Goose.app/Contents/MacOS/Goose")] + elif sys.platform.startswith("win"): + la = os.environ.get("LOCALAPPDATA", "") + if la: + candidates = [Path(la) / "Programs" / "goose" / "Goose.exe"] + else: # linux + candidates = [ + home / ".local" / "opt" / "goose" / "Goose", + Path("/usr/lib/goose/Goose"), + ] + w = shutil.which("Goose") or shutil.which("goose-desktop") + if w: + candidates.insert(0, Path(w)) + return next((str(p) for p in candidates if p.exists()), None) + + +# ── configure (idempotent, re-asserted every launch) ───────────────────────── +def configure(creds: config.Credentials, model: str | None = None) -> str: + """Write InferRoute's goose config + secrets. Returns the routing host.""" + model = model or _default_model() + host = _anthropic_host(creds) + + _write_merged( + CONFIG_FILE, + {"GOOSE_PROVIDER": "anthropic", "GOOSE_MODEL": model, "ANTHROPIC_HOST": host}, + secret=False, + ) + _write_merged( + SECRETS_FILE, + { + "ANTHROPIC_API_KEY": creds.api_key, + "ANTHROPIC_CUSTOM_HEADERS": {"x-inferroute-client": CLIENT_TAG}, + }, + secret=True, + ) + # goose stores secrets in the OS keyring by default; mgld-style headless boxes + # (and many Linux desktops) have no working keyring, so point goose at its file + # secret store. Set it for the GUI session too (Linux), so a menu-launched + # desktop also reads secrets.yaml. + if not sys.platform.startswith("win") and sys.platform != "darwin": + envd = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "environment.d" + try: + envd.mkdir(parents=True, exist_ok=True) + (envd / "99-inferroute-goose.conf").write_text("GOOSE_DISABLE_KEYRING=true\n") + except Exception: + pass + return host + + +def _launch_env() -> dict: + env = dict(os.environ) + env["GOOSE_DISABLE_KEYRING"] = "true" + return env + + +def _install_goose_cli(assume_yes: bool) -> bool: + if _goose_cli(): + return True + print("\n goose isn't installed yet.") + if not assume_yes: + resp = input(" Install the goose CLI now (open-source, ~from block/goose)? [Y/n] ").strip().lower() + if resp in ("n", "no"): + return False + print(" Installing goose…") + try: + rc = subprocess.call( + f"curl -fsSL {GOOSE_INSTALL_URL} | CONFIGURE=false bash", + shell=True, + ) + except Exception as e: # pragma: no cover + print(f" ✗ install failed: {e}") + return False + if rc != 0 or not _goose_cli(): + print(" ✗ goose install didn't complete. Install it manually: https://block.github.io/goose/") + return False + return True + + +# ── commands ───────────────────────────────────────────────────────────────── +def setup_cowork() -> int: + """Called from `ir setup`: install (if the user wants) + configure, no launch.""" + creds = config.load() + if not creds.is_valid: + print(" Skipping cowork — log in first (`ir login`).") + return 0 + _install_goose_cli(assume_yes=False) + host = configure(creds) + routed = "the on-device recorder" if "localhost" in host else "InferRoute" + print(f"\n ✓ Cowork is wired to {routed}.") + if _goose_desktop(): + print(" Launch the desktop app anytime, or run: ir cowork") + else: + print(f" Get the desktop app: {GOOSE_DESKTOP_DOWNLOAD}") + print(" Or use it in the terminal now: ir cowork") + return 0 + + +def cmd_cowork(rest: list[str]) -> int: + """`ir cowork [--cli] [--configure-only] [--model NAME] [-- ]`.""" + import argparse + + ap = argparse.ArgumentParser(prog="ir cowork", add_help=True) + ap.add_argument("--cli", action="store_true", help="run the goose CLI even if the desktop is installed") + ap.add_argument("--configure-only", action="store_true", help="wire goose to InferRoute, don't launch") + ap.add_argument("--model", default=None, help="model to pin (default: kimi)") + ns, passthrough = ap.parse_known_args(rest) + if passthrough and passthrough[0] == "--": + passthrough = passthrough[1:] + + creds = config.load() + if not creds.is_valid: + sys.stderr.write("\n Not logged in. Run `ir login` (or `ir setup`) first.\n\n") + return 2 + + model = None + if ns.model: + try: + from . import models + + alias = models.get(ns.model) + model = alias.model_id if alias is not None else ns.model + except Exception: + model = ns.model + + host = configure(creds, model=model) + + if ns.configure_only: + print(f" ✓ goose wired to InferRoute ({host}).") + return 0 + + # Launch: prefer the desktop (the point-and-click experience) unless --cli. + desktop = None if ns.cli else _goose_desktop() + env = _launch_env() + if desktop: + print(f" Launching goose desktop (InferRoute · {host})…") + args = [desktop] + if not (sys.platform == "darwin" or sys.platform.startswith("win")): + args.append("--no-sandbox") # chrome-sandbox isn't setuid in a user-local install + try: + subprocess.Popen(args, env=env, start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return 0 + except Exception as e: + print(f" ✗ couldn't launch the desktop ({e}); falling back to the CLI.") + + goose = _goose_cli() + if not goose: + if not _install_goose_cli(assume_yes=False): + return 1 + goose = _goose_cli() + print(f" Launching goose (InferRoute · {host})…") + os.execvpe(goose, [goose, *passthrough], env) + return 0 # never reached diff --git a/inferroute_cli/help.py b/inferroute_cli/help.py index 7109dad..246b387 100644 --- a/inferroute_cli/help.py +++ b/inferroute_cli/help.py @@ -28,6 +28,13 @@ def run(args=None) -> int: lines.append(" ir choose Same as bare `ir` — interactive picker, then launch") lines.append(" ir anthropic Escape hatch — plain claude, no inferroute env touched") lines.append("") + lines.append("Cowork — a desktop app for everyday work, powered by goose") + lines.append(" ir cowork Wire goose to inferroute and launch it (the desktop app if") + lines.append(" installed, else the CLI). Point-and-click; no terminal needed.") + lines.append(" ir cowork --cli Force the goose CLI instead of the desktop") + lines.append(" ir cowork --configure-only") + lines.append(" Wire goose to inferroute without launching") + lines.append("") lines.append("Deferred / economy lane") lines.append(" ir gate Poll the economy gate. Exit 0 = cheap window (run a cycle),") lines.append(" 1 = skip. Use in a loop: `if ir gate; then run; else sleep 30; fi`") diff --git a/inferroute_cli/main.py b/inferroute_cli/main.py index cc4ec8f..c041308 100644 --- a/inferroute_cli/main.py +++ b/inferroute_cli/main.py @@ -156,6 +156,12 @@ def main(argv: list[str] | None = None) -> int: from . import data as data_mod return data_mod.cmd_data(rest) + if cmd == "cowork": + # `ir cowork` — wire goose (open-source agent desktop/CLI) to InferRoute + # and launch it. The point-and-click surface for everyday work. + from . import cowork as cowork_mod + return cowork_mod.cmd_cowork(rest) + if cmd == "anthropic": # Escape hatch — no env, no key check, no inferroute touch. launch.launch_native_anthropic(extra_args=rest) diff --git a/inferroute_cli/setup.py b/inferroute_cli/setup.py index 39e84dd..0929d81 100644 --- a/inferroute_cli/setup.py +++ b/inferroute_cli/setup.py @@ -43,10 +43,29 @@ def run(rest=None) -> int: # setup on the result — a failure here shouldn't block a logged-in user. add_mod.cmd_add(["recording", "--level", "full", "--yes"]) + # ── Step 3: cowork (optional) ───────────────────────────────────── + # A desktop app for everyday (non-terminal) work, powered by the open-source + # agent goose. Opt-in — most CLI users won't want it, so we ask rather than + # default it on. + print("\n [3/3] Cowork — a desktop app for InferRoute, powered by goose (optional).") + print(" Point-and-click way to use InferRoute for everyday work — research,") + print(" writing, files — no terminal needed. Open-source (github.com/block/goose).") + try: + resp = input(" Set it up now? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + resp = "" + if resp in ("y", "yes"): + from . import cowork as cowork_mod + + cowork_mod.setup_cowork() + else: + print(" Skipped. Add it later with `ir cowork`.") + # ── Done ────────────────────────────────────────────────────────── print() print(" ✓ You're all set. Start any time with:") print(" ir # pick a model, then launch") print(" ir --model minimax # or pin one directly") + print(" ir cowork # the desktop app, powered by goose") print() return 0 diff --git a/pyproject.toml b/pyproject.toml index 755eb21..e00a108 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ dependencies = [ "textual>=0.78,<2.0", "httpx>=0.27,<1.0", + "pyyaml>=6.0", # `ir cowork` reads/merges goose's config.yaml + secrets.yaml ] # Optional on-device recorder. Adds a local HTTP daemon that intercepts CC