r""" officecli — a thin Python shell over officecli's resident pipe. It does ONE thing: forward a command to the running resident over its named pipe and hand back the response. There is NO second vocabulary to learn: a command is the same dict you'd put in an officecli `batch` list — e.g. {"command":"set","path":"/Sheet1/A1","props":{"text":"Hello"}}. `send` forwards one; `batch` forwards many in a single round-trip. Two surfaces, by design: - bootstrap (infrequent): `create` / `open` spawn ONE CLI process — a file that isn't open yet (or doesn't exist yet) has no resident to talk to. - everything else (the hot path): `send` / `batch` are pure pipe round-trips, no per-command process spawn. import officecli with officecli.create("report.xlsx", "--force") as doc: # make file + get handle doc.send({"command": "set", "path": "/Sheet1/A1", "props": {"text": "Hello"}}) print(doc.send({"command": "get", "path": "/Sheet1/A1"})) doc.send({"command": "save"}) # ...or officecli.open("existing.xlsx") for a file that already exists. The item keys are officecli's batch fields (command/op, path, parent, type, index, after, before, to, selector, text, mode, depth, part, xpath, action, xml) plus a nested `props` dict. Everything except command/op/props is forwarded verbatim as a command argument; the resident dispatches it exactly like the matching CLI command. See `officecli help` / the batch docs for the field-and-prop reference — this shell adds none of its own. Protocol (matches ResidentServer.cs / ResidentClient.cs): - pipe name : officecli-; fullpath upper-cased on macOS/Windows, left as-is on Linux. - unix path : $TMPDIR/CoreFxPipe_ (+ "-ping"); $TMPDIR else /tmp - win path : \\.\pipe\ (+ "-ping") - framing : one request line + one response line, UTF-8, '\n' terminated; one connection == one command. - request : PascalCase {"Command","Args","Props","Json"} - response : {"ExitCode","Stdout","Stderr"} """ import os import sys import json import time import socket import hashlib import shutil import threading import subprocess # Mirror officecli's TryResident busy-delivery policy (CommandBuilder.cs): a # generous connect timeout + a few retries with backoff, applied identically to # every command. The reply read itself blocks (no timeout) — like officecli's # PipeReadLine — trusting the resident to answer once our turn comes up in its # serialized queue. Because retries only re-attempt the CONNECT (before the # command executes), re-sending is safe even for mutations; there is no # "read timed out, resend" path that could double-apply. _BUSY_CONNECT_TIMEOUT = 30.0 # = ResidentBusyConnectTimeoutMs (30000) _BUSY_MAX_RETRIES = 3 # = ResidentBusyMaxRetries # = CommandBuilder's DefaultOpenIdleSeconds. `open` upgrades a reused resident # (which `create` may have auto-started with a short 60s timeout) to the 12min # interactive window, so a long editing session over an SDK handle isn't cut # short by the create-time timeout. _OPEN_IDLE_SECONDS = 12 * 60 _IS_WIN = sys.platform.startswith("win") _IS_MAC = sys.platform == "darwin" _builtin_open = open # preserved; this module defines its own open() below # officecli's official installer (README one-liner). install() shells out to it; # the missing-CLI error points users at it / at install(). # Installer scripts: the d.officecli.ai mirror is primary; GitHub raw is only a # fallback (same order as install.sh / install.ps1 themselves). The mirror is # Cloudflare-fronted and reachable where raw.githubusercontent.com may be # rate-limited or blocked. _INSTALL_SH_MIRROR = "https://d.officecli.ai/install.sh" _INSTALL_SH_GITHUB = "https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh" _INSTALL_PS1_MIRROR = "https://d.officecli.ai/install.ps1" _INSTALL_PS1_GITHUB = "https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1" _MISSING_CLI = ( "officecli CLI not found: {bin!r} is not on PATH nor in the default install " "location (~/.local/bin, or %LOCALAPPDATA%\\OfficeCLI on Windows). This SDK only forwards " "commands to the officecli binary, which must be installed separately. Install it:\n" " python -m officecli install # runs the official installer\n" " # or: curl -fsSL " + _INSTALL_SH_MIRROR + " | bash\n" "Already installed elsewhere? pass binary=\"/path/to/officecli\"." ) class OfficeCliError(Exception): """Raised on transport/process failure (could not reach the resident). Business outcomes are NOT exceptions — they live in the returned envelope's 'success' field, same as the CLI's exit code.""" def __init__(self, code, msg): super().__init__(f"[exit {code}] {msg}") self.code = code # ---------------------------------------------------------------- pipe address def _dotnet_tempdir(): # Mirror .NET Path.GetTempPath() on Unix exactly: $TMPDIR else /tmp. return os.environ.get("TMPDIR") or "/tmp" def _canonical_path(file_path): """Match the path officecli's resident hashes into the pipe name. On Windows it derives the name from GetFullPath, which expands 8.3 short components (RUNNER~1, or any user name > 8 chars under %TEMP%) to their long form. os.path.abspath does NOT expand 8.3, so a short path hashes to a different pipe and every connect fails with ENOENT — hence realpath, which does expand it. realpath needs the file to exist; fall back to the abspath when it doesn't. realpath ALSO resolves symlinks/junctions, which GetFullPath does not; harmless here because we hand this resolved path to the resident, so the server's GetFullPath sees the already-resolved string and both sides hash the same thing. Windows only — on unix officecli uses GetFullPath (no symlink resolution), so realpath would diverge there (e.g. /tmp -> /private/tmp on macOS).""" resolved = os.path.abspath(file_path) if _IS_WIN: try: return os.path.realpath(resolved) except OSError: pass return resolved def pipe_paths(file_path): """(main, ping) pipe addresses for a document path. Exposed for debugging.""" full = _canonical_path(file_path) if _IS_MAC or _IS_WIN: full = full.upper() # Linux: case-sensitive, no upper h = hashlib.sha256(full.encode("utf-8")).hexdigest().upper()[:16] name = f"officecli-{h}" if _IS_WIN: return rf"\\.\pipe\{name}", rf"\\.\pipe\{name}-ping" base = os.path.join(_dotnet_tempdir(), f"CoreFxPipe_{name}") return base, base + "-ping" # ---------------------------------------------------------------- transport # One attempt: bound the CONNECT, then block on the reply (no read timeout) — # exactly like officecli's TrySend (Connect(timeout) + blocking PipeReadLine). def _send_unix(sock_path, line, connect_timeout): s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: s.settimeout(connect_timeout) s.connect(sock_path) s.settimeout(None) # block on the reply; resident answers in turn s.sendall(line) buf = b"" while not buf.endswith(b"\n"): chunk = s.recv(65536) if not chunk: break buf += chunk return buf finally: s.close() def _send_win(pipe_path, line, connect_timeout): deadline = time.time() + connect_timeout while True: # bound the "open" (connect) phase try: f = _builtin_open(pipe_path, "r+b", buffering=0) # not the module open() break except FileNotFoundError: # No pipe == no resident. Fail FAST, like _send_unix's connect() # raising ENOENT immediately — do NOT spin to the deadline. This is # what makes a max_retries=0 probe (_serves/alive) fail fast instead # of sitting through the whole connect_timeout when nothing is there. raise except OSError: # The pipe exists but the open lost the race (e.g. ERROR_PIPE_BUSY: # every server instance is mid-handoff). The resident IS alive, so # retry until the connect deadline. if time.time() > deadline: raise time.sleep(0.02) try: # FileIO.write (raw, buffering=0) issues a single WriteFile and may # return a short count, so loop until the whole request is out — a # truncated request leaves the resident blocking for a newline that # never comes, deadlocking the (untimed) reply read. Mirrors _send_unix's # sendall() and the C# client's Stream.Write. view = memoryview(line) sent = 0 while sent < len(view): n = f.write(view[sent:]) if n is None: # non-blocking handle not ready (shouldn't happen) continue sent += n buf = b"" while not buf.endswith(b"\n"): # blocking read, like PipeReadLine chunk = f.read(65536) if not chunk: break buf += chunk return buf finally: f.close() def _rpc(sock_path, req, connect_timeout=_BUSY_CONNECT_TIMEOUT, max_retries=_BUSY_MAX_RETRIES): """Forward one request, mirroring officecli's TrySend: bounded connect + a few retries with backoff, then a blocking read. A retry only re-attempts the connect (before the command runs), so it never double-applies a mutation. If the command still can't be delivered, raise a busy/unresponsive error — never fall back to touching the file directly (that would race the resident). `max_retries` overrides the busy-retry count. Liveness probes (_serves) pass 0 so a missing/stale pipe fails FAST instead of sleeping through ~0.3s of backoff — retrying a probe the resident isn't answering can't make it answer; the busy-retry policy is for delivering a real command to a slow-but-live pipe.""" line = (json.dumps(req, ensure_ascii=False) + "\n").encode("utf-8") send = _send_win if _IS_WIN else _send_unix for attempt in range(max_retries + 1): try: raw = send(sock_path, line, connect_timeout) break except OSError as e: if attempt >= max_retries: raise OfficeCliError(-1, f"resident is running but the command could not be delivered " f"(pipe busy or unresponsive); retry, or close and reopen [{e}]") time.sleep(0.05 * (attempt + 1)) # = TrySend's 50*(n+1)ms backoff # utf-8-sig: the resident's StreamWriter (Encoding.UTF8) prepends a BOM the # C# StreamReader strips; we must too, or json.loads chokes on the leading . text = raw.decode("utf-8-sig") if not text.strip(): # Empty/closed reply: the resident accepted the connection but closed # without a complete response (e.g. crashed mid-serve). We refuse to # re-send — the command may already have been APPLIED before the resident # died, so re-sending would double-apply a non-idempotent op — and raise # instead. officecli's TrySend now matches: its retry covers only the # connect phase (before the command is written); on an empty reply after a # successful write it returns null without re-sending, the C# equivalent of # this raise. _cmd's recovery then restarts a dead resident and retries once # (a fresh connect, before re-send), and _serves()/alive() (which swallow # OfficeCliError) read an empty reply as "not alive". raise OfficeCliError(-1, "resident closed the connection without a response " "(it may have crashed mid-command); retry, or close and reopen") return json.loads(text) def _parse(resp): """Return the useful payload: the parsed JSON envelope (dict/list) if Stdout is a JSON object/array, otherwise the raw Stdout text ("" when empty). We accept ONLY dict/list from json.loads — a text-mode reply that happens to BE a bare JSON scalar ("42", "true", "null", a quoted string) must stay text, or the caller can't tell literal text "42" from the number 42 (and None from a missing key). Faithful to the response — no synthesizing a dict for view/raw text.""" out = resp.get("Stdout", "") try: v = json.loads(out) except ValueError: return out return v if isinstance(v, (dict, list)) else out def _strv(d): # Drop None-valued props (omit), matching how _cmd() drops None args — a prop # set to None means "don't send it", not "send empty string". Pass "" for # an explicit empty value. return {k: str(v) for k, v in d.items() if v is not None} def _serves(ping_path, full_path, timeout=1.0): """Is a resident alive on `ping_path` AND serving `full_path`? Probes the always-responsive `-ping` pipe (officecli's TryConnect equivalent): it answers even while the MAIN pipe is busy. The path-match guards against a stale socket serving a different/renamed file. `full_path` must already be absolute. Single-shot (max_retries=0): a probe should fail fast, not sit through the busy-retry backoff that a real command delivery uses.""" try: resp = _rpc(ping_path, {"Command": "__ping__"}, timeout, max_retries=0) except OfficeCliError: return False served = resp.get("Stdout", "").strip() # ping echoes the served file path if not served: return False a = os.path.abspath(served) return a == full_path or ((_IS_MAC or _IS_WIN) and a.lower() == full_path.lower()) def _install_dir_candidate(name): """Where the official installer (install.sh / install.ps1) drops the binary: ~/.local/bin on macOS/Linux, %LOCALAPPDATA%\\OfficeCLI on Windows. Used only as a PATH-miss fallback (see _resolve_binary).""" if _IS_WIN: base = os.environ.get("LOCALAPPDATA") if not base: return None exe = name if name.lower().endswith(".exe") else name + ".exe" return os.path.join(base, "OfficeCLI", exe) return os.path.join(os.path.expanduser("~"), ".local", "bin", name) def _resolve_binary(binary): """Resolve the officecli binary to invoke. Order: explicit path (a value with a path separator) is trusted as-is; otherwise a bare name is looked up on PATH; if PATH misses, fall back to the official installer's known location. Why the fallback: the installer adds its dir to PATH via the shell rc file, so a bare 'officecli' resolves in an interactive terminal — but NOT in processes that never sourced that rc (IDE-spawned Python, cron, systemd, CI). The binary is still sitting at the known install path; find it there instead of failing. Idempotent: an already-resolved absolute path passes straight through, so it's safe to call at every entry point (create + Document).""" if os.sep in binary or (os.altsep and os.altsep in binary): return binary # explicit path: trust the caller found = shutil.which(binary) if found: return found # on PATH: normal case cand = _install_dir_candidate(binary) # PATH miss: try the known install dir if cand and os.path.isfile(cand) and os.access(cand, os.X_OK): return cand return binary # give up; _run_cli raises the helpful error def _runs_ok(binary): """True iff ` --version` actually runs and exits 0. Accept only a WORKING officecli — skip a present-but-broken file, and don't trigger a needless install when a usable officecli is already there.""" try: return subprocess.run([binary, "--version"], capture_output=True).returncode == 0 except OSError: return False def _ensure_binary(binary, auto_install=True): """Resolve to a WORKING officecli, provisioning one if none is found and auto_install is set. An explicit path (with a separator) is trusted as-is; otherwise each candidate (PATH, then the installer's known location) is accepted only when `officecli --version` actually runs — so a present-but- broken binary is skipped and a usable one never triggers a needless install. install() picks install.sh (unix) or install.ps1 (Windows), so auto-install works on both.""" if os.sep in binary or (os.altsep and os.altsep in binary): return binary # explicit path: trust the caller for cand in filter(None, (shutil.which(binary), _install_dir_candidate(binary))): if _runs_ok(cand): return cand # a working officecli is already here if auto_install: print("officecli CLI not found — installing from d.officecli.ai ...", file=sys.stderr) install() # CLI absent/unusable → official installer for cand in filter(None, (shutil.which(binary), _install_dir_candidate(binary))): if _runs_ok(cand): return cand return binary # give up; _run_cli raises the helpful error def _run_cli(binary, argv): """Run `binary ` (capturing output). A missing binary surfaces as a clear OfficeCliError with install guidance, not a raw FileNotFoundError.""" try: return subprocess.run([binary, *argv], capture_output=True, text=True) except FileNotFoundError: raise OfficeCliError(127, _MISSING_CLI.format(bin=binary)) from None # ---------------------------------------------------------------- the shell class Document: def __init__(self, path, binary="officecli", timeout=30.0): # Canonical (Windows 8.3-expanded) so the pipe name AND the _serves() # path comparison both match what the resident reports. self.path = _canonical_path(path) self.bin = _resolve_binary(binary) self.timeout = timeout # connect timeout (s); the reply read blocks self._main, self._ping = pipe_paths(self.path) self._restart_lock = threading.Lock() # serialize dead-resident restarts self._start() def _start(self): # If a resident is ALREADY serving this file, reuse it — no process spawn. # Mirrors officecli, where a command after `create` reuses the resident # `create` auto-started instead of re-running `open`. _serves() is a real # liveness probe (ping the -ping pipe + verify the served path), not a # socket-file-exists check, so a stale/dead socket fails the probe and # falls through to `officecli open`, which replaces it via TryConnect. # (A plain os.path.exists() here would wrongly skip on a stale socket.) if _serves(self._ping, self.path): return # Otherwise spawn `officecli open` (one process). It's idempotent and uses # the same TryConnect to start a fresh resident or replace a stale socket. r = _run_cli(self.bin, ["open", self.path]) if r.returncode != 0: raise OfficeCliError(r.returncode, r.stderr or r.stdout) # -- transport primitive: build {Command,Args,Props,Json}, forward, parse -- def _cmd(self, command, args=None, props=None, as_json=True, timeout=None): # `as_json`, not `json`, so we don't shadow the imported json module. # timeout=None uses this Document's default (self.timeout). It bounds the # CONNECT/delivery (with retries); the reply read blocks, so a legitimately # slow command isn't cut off — it waits for the resident, like officecli. req = {"Command": command, "Json": as_json} if args: req["Args"] = {k: str(v) for k, v in args.items() if v is not None} if props is not None: req["Props"] = _strv(props) t = self.timeout if timeout is None else timeout try: return _rpc(self._main, req, t) except OfficeCliError: # Delivery failed after _rpc's own connect retries. Use the -ping pipe # to tell DEAD from BUSY — officecli's own distinction (alive()): # • ALIVE but main pipe unresponsive → do NOT bypass it. officecli # deliberately dropped the direct-file fallback: a second writer # racing the live resident loses data on its eventual save. Re-raise # the busy error so the caller can retry or close+reopen. # • DEAD (crashed / stale socket) → restart with one `officecli open` # and retry ONCE. Safe across reads and mutations: mutations live in # memory until save/close, so a crash loses them and disk holds the # last save — replaying against the restarted (disk-state) resident # reproduces the lost op once, with nothing live to double-apply. if self.alive(): raise # Serialize the restart across threads sharing this Document. Without # the lock, N concurrent callers each see alive()==False and each spawn # `officecli open`, leaving N-1 orphaned residents on the same file # (which can then race each other's save). Re-check alive() inside the # lock so only the first thread restarts; the rest find it back up. with self._restart_lock: if not self.alive(): self._start() return _rpc(self._main, req, t) # -- the surface: send ONE batch-shaped command, or a LIST of them --------- def send(self, item, as_json=True, timeout=None): """Forward ONE command in officecli's batch-item shape and return its parsed result (the JSON envelope, or raw text for content commands). `item` is exactly a dict you'd put in a `batch` list, e.g. {"command": "set", "path": "/Sheet1/A1", "props": {"text": "hi"}} {"command": "get", "path": "/Sheet1/A1"} Keys are officecli's batch fields; `command` (or `op`) picks the command, `props` becomes the property map, and every other key is forwarded verbatim as a command argument — no field list maintained here, so new officecli fields work without touching this shell. `as_json=False` requests plain-text output (view/raw/dump), mirroring the CLI's --json toggle.""" command = item.get("command") or item.get("op") if not command: raise OfficeCliError(-1, "send(item): item needs a 'command' (or 'op') key") args = {k: v for k, v in item.items() if k not in ("command", "op", "props")} return _parse(self._cmd(command, args, item.get("props"), as_json=as_json, timeout=timeout)) def batch(self, items, force=True, stop_on_error=False, timeout=None): """Forward officecli's `batch` command: apply a LIST of the same item dicts as `send` in ONE round-trip — the fast path for many writes. Same contract as `send`, just plural.""" args = {"batchJson": json.dumps(items, ensure_ascii=False), "force": force, "stopOnError": stop_on_error} return _parse(self._cmd("batch", args, timeout=timeout)) def _set_idle_timeout(self, seconds): # Best-effort idle-timeout upgrade, served on the always-responsive ping # pipe (bypasses _commandLock, answers even while the main pipe is busy). # Mirrors ResidentClient.SendSetIdleTimeout: a failure is non-fatal — the # resident is still usable, it just keeps its original idle schedule. # Single-shot (max_retries=0): don't sit through the busy backoff for a # best-effort nicety. try: _rpc(self._ping, {"Command": "__set-idle-timeout__", "Args": {"seconds": str(seconds)}}, self.timeout, max_retries=0) except OfficeCliError: pass def alive(self, timeout=1.0): """Return True iff a resident is alive AND serving this file. Probes the always-responsive `-ping` pipe (officecli's TryConnect), which answers even while the MAIN pipe is busy — so it distinguishes "alive but busy" from "gone". This is the discriminator `_cmd` uses on a delivery failure (busy → raise, gone → restart+retry); send/batch already auto-recover from a gone resident, so call this only when you want to check liveness yourself.""" return _serves(self._ping, self.path, timeout) # -- lifecycle ------------------------------------------------------------ def close(self): # = `officecli close`: stop the resident. It flushes the in-memory doc to # disk as it shuts down (handler.Dispose), so no separate save is needed — # verified: a set followed by __close__ alone lands on disk. # # The resident acks AFTER shutting down, so a missing/empty ack (lost to a # crash or the 5s write-timeout) still means "closed". A real shutdown # data-loss is a NON-empty error response, so it surfaces through _parse. try: return _parse(_rpc(self._ping, {"Command": "__close__"}, self.timeout)) except OfficeCliError: # Only swallow if the resident is actually gone. If it's still alive # (ping pipe was momentarily unreachable/busy), the close did NOT take # effect — re-raise, or the caller wrongly believes the file is released # and may race a re-open/overwrite. if self.alive(): raise return "" # resident gone / ack lost — end state is "closed" def __enter__(self): return self def __exit__(self, *a): # `with` means "I manage this session" → close on exit. To only borrow a # resident another program owns, DON'T use `with` and DON'T call close(): # d = officecli.open(f); d.send(...) # left running self.close() def create(path, *args, binary="officecli", timeout=30.0, auto_install=True): """Create a blank Office document and return a live `Document` handle for it. Parallel to `open`: both return the session handle you actually work with — they differ only in the file's expected state. `open` requires an existing file; `create` makes a new one (like file mode "x" vs "r"). Extra CLI flags pass through verbatim, so there's no option list maintained here: with officecli.create("report.xlsx", "--force") as doc: doc.send({"command": "set", "path": "/Sheet1/A1", "props": {"text": "hi"}}) officecli.create("doc", "--type", "docx") One CLI spawn (`officecli create`), which also auto-starts a resident for the new file; the returned Document binds to THAT resident (no second spawn). Raises OfficeCliError on failure, inheriting officecli's exact semantics: • file held by a LIVE resident → file_locked (close it first). We do NOT silently close+overwrite it — in a shared workspace that resident may be another owner's active session. • file exists without --force → file_exists (pass "--force" to overwrite).""" full = os.path.abspath(path) binary = _ensure_binary(binary, auto_install) r = _run_cli(binary, ["create", full, *args]) if r.returncode != 0: raise OfficeCliError(r.returncode, r.stderr or r.stdout) # create auto-started a resident for the new file; bind a handle to it # (Document.__init__ -> _start -> _serves finds it alive, so no extra spawn). return Document(full, binary=binary, timeout=timeout) def open(path, binary="officecli", timeout=30.0, auto_install=True): """Open an EXISTING document and return a live `Document` handle (parallel to `create`, which makes a new file). `officecli open` is idempotent: it reuses a resident already serving this file or starts one — and if a live resident is already up, no process is spawned at all. Lifecycle: Owner — `with officecli.open(f) as d: ...` (exit closes the resident) Borrow — `d = officecli.open(f); d.send(...)` (no `with`/close → left running) Failure model (applies to every send/batch on the handle): • resident DEAD/gone (crash, idle-timeout, missing pipe) → transparently restarted and the command retried once; the caller sees no error. • resident ALIVE but the pipe is unresponsive (busy) → raises OfficeCliError — never a deadlock, and never bypassing the live resident (that would race its save and lose data). Retry, or close() and reopen. `timeout` bounds command DELIVERY (connect + retries) in seconds, mirroring officecli's TrySend; the reply read itself blocks (a busy resident answers in turn). Override per call via send(..., timeout=...) / batch(..., timeout=...); use alive() to probe liveness.""" doc = Document(path, binary=_ensure_binary(binary, auto_install), timeout=timeout) # Mirror CLI `open`: when reusing a resident `create` auto-started with a # short 60s timeout, upgrade it to the 12min interactive window. (If _start # spawned `officecli open` instead, that path already set 12min; re-sending # is idempotent.) doc._set_idle_timeout(_OPEN_IDLE_SECONDS) return doc def install(): """Install the officecli CLI binary via its OFFICIAL installer — install.sh on unix, install.ps1 on Windows. Reuses officecli's own installers (platform detection + checksum + ~/.local/bin or %LOCALAPPDATA%\\OfficeCLI), rather than reimplementing download logic that would drift from upstream. Called automatically by open()/create() when the CLI is missing (pass auto_install=False to disable), and exposed directly as `python -m officecli install`. Returns None on success; raises OfficeCliError on failure. Output is NOT captured, so the installer's progress and checksum lines stream to the user.""" if _IS_WIN: print(f"Installing officecli via {_INSTALL_PS1_MIRROR} (github fallback) ...", file=sys.stderr) # Windows PowerShell (powershell.exe) ships with the OS; -ExecutionPolicy # Bypass lets the remote script run without changing machine policy. Fetch # the script mirror-first, github fallback, then run it. ps = (f"$s = try {{ irm '{_INSTALL_PS1_MIRROR}' }} " f"catch {{ irm '{_INSTALL_PS1_GITHUB}' }}; $s | iex") r = subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps]) if r.returncode != 0: raise OfficeCliError(r.returncode, f"officecli install failed (exit {r.returncode}). Run manually:\n" f" irm {_INSTALL_PS1_MIRROR} | iex") return None print(f"Installing officecli via {_INSTALL_SH_MIRROR} (github fallback) ...", file=sys.stderr) # (curl mirror || curl github) | bash — the subshell emits whichever fetch # succeeds; the group keeps the pipe bound to the whole fallback. Output is # NOT captured, so progress and checksum lines stream to the user. sh = f"(curl -fsSL {_INSTALL_SH_MIRROR} 2>/dev/null || curl -fsSL {_INSTALL_SH_GITHUB}) | bash" r = subprocess.run(["bash", "-c", sh]) if r.returncode != 0: raise OfficeCliError(r.returncode, f"officecli install failed (exit {r.returncode}). Run manually:\n" f" curl -fsSL {_INSTALL_SH_MIRROR} | bash") return None # Advertised surface = the command shell + its error. pipe_paths stays importable # (officecli.pipe_paths) as a debug helper but isn't part of the command API. __all__ = ["open", "create", "install", "Document", "OfficeCliError"] if __name__ == "__main__": # `python -m officecli install` — bootstrap the CLI binary. if len(sys.argv) >= 2 and sys.argv[1] == "install": install() else: print("usage: python -m officecli install", file=sys.stderr) sys.exit(2)