diff --git a/CLAUDE.md b/CLAUDE.md index b05eb65..b3ba703 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ while (pos < slice.len) { ... } The `<= len` pattern is only correct when `pos` represents a position *after* processing (e.g., `slice[0..pos]` as a "processed so far" marker). ## Build and Test (required after every task) -- Run `zig build` and `zig build test` (or `just ci` when appropriate) once the task is complete. +- Run `zig build`, `zig build test`, and `just lint` (or `just ci` when appropriate) once the task is complete. - Report the results in your summary; if you must skip tests, state the reason explicitly. ## Documentation Hygiene (REQUIRED) diff --git a/README.md b/README.md index 12a4db7..1ab4660 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Common settings include font family, theme colors, and grid font scale. The grid ## Documentation -* [`docs/ai-integration.md`](docs/ai-integration.md): set up Claude Code, Codex, and Gemini CLI hooks for status notifications. +* [`docs/ai-integration.md`](docs/ai-integration.md): set up Claude Code, Codex, and Gemini CLI hooks for status notifications (includes `architect notify`, `architect hook ...`, and timestamped backups). * [`docs/architecture.md`](docs/architecture.md): architecture overview and system boundaries. * [`docs/configuration.md`](docs/configuration.md): detailed configuration reference for `config.toml` and `persistence.toml`. * [`docs/development.md`](docs/development.md): build, test, and release process. diff --git a/docs/ai-integration.md b/docs/ai-integration.md index 87684f4..b293d9d 100644 --- a/docs/ai-integration.md +++ b/docs/ai-integration.md @@ -15,9 +15,37 @@ Examples: {"session": 0, "state": "done"} ``` +## Built-in Command (inside Architect terminals) + +Architect injects a small `architect` command into each shell's `PATH`. It reads the +session id and socket path from the environment, so hooks can simply call: + +```bash +architect notify start +architect notify awaiting_approval +architect notify done +``` + +If your hook runs outside an Architect terminal, use the Python helper scripts below. +Replace `architect notify ...` in the examples with `python3 ~/./architect_notify.py ...` when using those scripts. + +## Hook Installer + +From inside an Architect terminal, you can install hooks automatically: + +```bash +architect hook claude +architect hook codex +architect hook gemini +``` + +If you upgrade Architect, restart existing terminals so the bundled `architect` script refreshes. +The installer writes timestamped backups before updating configs (for example: +`settings.json.architect.bak.20260127T153045Z`). + ## Claude Code Hooks -1. Copy the helper script: +1. (Optional) Copy the helper script if the hook runs outside Architect: ```bash cp scripts/architect_notify.py ~/.claude/architect_notify.py chmod +x ~/.claude/architect_notify.py @@ -32,7 +60,7 @@ Examples: "hooks": [ { "type": "command", - "command": "python3 ~/.claude/architect_notify.py done || true" + "command": "architect notify done || true" } ] } @@ -42,7 +70,7 @@ Examples: "hooks": [ { "type": "command", - "command": "python3 ~/.claude/architect_notify.py awaiting_approval || true" + "command": "architect notify awaiting_approval || true" } ] } @@ -53,7 +81,7 @@ Examples: ## Codex Hooks -1. Copy the helper script: +1. (Optional) Copy the helper script if the hook runs outside Architect: ```bash cp scripts/architect_notify.py ~/.codex/architect_notify.py chmod +x ~/.codex/architect_notify.py @@ -61,12 +89,18 @@ Examples: 2. Add the `notify` setting to `~/.codex/config.toml`: ```toml - notify = ["python3", "/Users/your-username/.codex/architect_notify.py"] + notify = ["architect", "notify"] ``` +If you already have `notify` configured, `architect hook codex` overwrites it, +prints a warning, and prints the backup file name. + ## Gemini CLI Hooks -1. Copy the notification scripts: +Gemini hooks must emit JSON to stdout, so keep using the wrapper script even inside +Architect terminals (it can call `architect notify` under the hood). + +1. Copy the notification scripts (the `architect hook gemini` installer assumes they exist): ```bash cp scripts/architect_notify.py ~/.gemini/architect_notify.py cp scripts/architect_hook_gemini.py ~/.gemini/architect_hook.py diff --git a/docs/architecture.md b/docs/architecture.md index b9a2d5d..3997d4a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -323,6 +323,10 @@ Protocol: Single-line JSON {"session": 0, "state": "start"} ``` +Each shell also gets a small `architect` command in `PATH` that wraps this protocol +and reads `ARCHITECT_SESSION_ID`/`ARCHITECT_NOTIFY_SOCK` (for example, +`architect notify done`). + A background thread (`notify.zig`) accepts connections, parses messages, and pushes to a thread-safe `NotificationQueue`. Main loop drains queue each frame. ## First Frame Guard Pattern diff --git a/scripts/architect_hook_gemini.py b/scripts/architect_hook_gemini.py index 8a5458c..cb77e47 100755 --- a/scripts/architect_hook_gemini.py +++ b/scripts/architect_hook_gemini.py @@ -3,13 +3,14 @@ Gemini CLI hook wrapper for Architect notifications. Gemini hooks receive JSON via stdin and must output JSON to stdout. -This wrapper consumes stdin, calls architect_notify.py, and returns valid JSON. +This wrapper consumes stdin, calls Architect notify, and returns valid JSON. """ import json import os -import sys +import shutil import subprocess +import sys def main() -> int: @@ -29,13 +30,21 @@ def main() -> int: state = sys.argv[1] - # Call architect_notify.py script - script_path = os.path.join(os.path.dirname(__file__), "architect_notify.py") - subprocess.run( - ["python3", script_path, state], - check=False, - capture_output=True, - ) + # Prefer built-in architect command when available, fall back to script. + architect_cmd = shutil.which("architect") + if architect_cmd: + subprocess.run( + [architect_cmd, "notify", state], + check=False, + capture_output=True, + ) + else: + script_path = os.path.join(os.path.dirname(__file__), "architect_notify.py") + subprocess.run( + ["python3", script_path, state], + check=False, + capture_output=True, + ) # Return success JSON to Gemini (required) print(json.dumps({"decision": "allow"})) diff --git a/src/shell.zig b/src/shell.zig index 8fe7a23..2efe08a 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -25,6 +25,10 @@ var terminfo_available: bool = false; var terminfo_dir_buf: [std.fs.max_path_bytes]u8 = undefined; var terminfo_dir_z: ?[:0]const u8 = null; var tic_path_buf: [std.fs.max_path_bytes]u8 = undefined; +var architect_command_setup_done: bool = false; +var architect_command_dir_buf: [std.fs.max_path_bytes]u8 = undefined; +var architect_command_path_buf: [std.fs.max_path_bytes]u8 = undefined; +var architect_command_dir_z: ?[:0]const u8 = null; const fallback_term = "xterm-256color"; const architect_term = "xterm-ghostty"; @@ -34,6 +38,431 @@ const default_term_program = "Architect"; // Architect terminfo: xterm-256color base + 24-bit truecolor + kitty keyboard protocol const architect_terminfo_src = assets.xterm_ghostty; +const architect_command_script = + \\#!/usr/bin/env python3 + \\""" + \\Architect shell helper for sending UI notifications and installing hooks. + \\ + \\Usage: + \\ architect notify + \\ architect notify (reads stdin) + \\ architect hook claude|codex|gemini + \\""" + \\import datetime + \\import json + \\import os + \\import shutil + \\import socket + \\import sys + \\ + \\try: + \\ import tomllib + \\except ImportError: + \\ tomllib = None + \\ + \\VALID_STATES = {"start", "awaiting_approval", "done"} + \\ + \\CLAUDE_DONE = "architect notify done || true" + \\CLAUDE_APPROVAL = "architect notify awaiting_approval || true" + \\CLAUDE_NEEDLES = ("architect notify", "architect_notify.py") + \\ + \\GEMINI_DONE = "python3 ~/.gemini/architect_hook.py done" + \\GEMINI_APPROVAL = "python3 ~/.gemini/architect_hook.py awaiting_approval" + \\GEMINI_NEEDLES = ("architect_hook.py", "architect notify") + \\ + \\CODEX_NOTIFY = ["architect", "notify"] + \\ + \\def notify_architect(state: str) -> None: + \\ session_id = os.environ.get("ARCHITECT_SESSION_ID") + \\ sock_path = os.environ.get("ARCHITECT_NOTIFY_SOCK") + \\ + \\ if not session_id or not sock_path: + \\ return + \\ + \\ try: + \\ message = json.dumps({ + \\ "session": int(session_id), + \\ "state": state + \\ }) + "\n" + \\ + \\ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + \\ sock.connect(sock_path) + \\ sock.sendall(message.encode()) + \\ sock.close() + \\ except Exception: + \\ pass + \\ + \\def state_from_notification(raw: str) -> str | None: + \\ raw = raw.strip() + \\ if not raw: + \\ return None + \\ + \\ if raw in VALID_STATES: + \\ return raw + \\ + \\ try: + \\ payload = json.loads(raw) + \\ except json.JSONDecodeError: + \\ return None + \\ + \\ if not isinstance(payload, dict): + \\ return None + \\ + \\ state_field = payload.get("state") + \\ if isinstance(state_field, str) and state_field in VALID_STATES: + \\ return state_field + \\ + \\ status = payload.get("status") + \\ if isinstance(status, str): + \\ lowered = status.lower() + \\ if lowered in VALID_STATES: + \\ return lowered + \\ if lowered in ("complete", "completed", "finished", "success"): + \\ return "done" + \\ if "approval" in lowered or "permission" in lowered: + \\ return "awaiting_approval" + \\ + \\ ntype = str(payload.get("type") or "").lower() + \\ if ntype: + \\ if ntype in VALID_STATES: + \\ return ntype + \\ if "approval" in ntype or "permission" in ntype or ( + \\ "input" in ntype and "await" in ntype + \\ ): + \\ return "awaiting_approval" + \\ if "complete" in ntype or ntype.endswith("-done"): + \\ return "done" + \\ if "start" in ntype or "begin" in ntype: + \\ return "start" + \\ + \\ return None + \\ + \\def warn_unmapped(raw: str) -> None: + \\ if sys.stderr.isatty(): + \\ print(f"Ignoring unmapped notification: {raw}", file=sys.stderr) + \\ + \\def print_usage() -> None: + \\ if sys.stderr.isatty(): + \\ print("Usage: architect notify ", file=sys.stderr) + \\ print(" architect notify (reads stdin)", file=sys.stderr) + \\ print(" architect hook claude|codex|gemini", file=sys.stderr) + \\ + \\def timestamp_suffix() -> str: + \\ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + \\ + \\def backup_path(path: str) -> str: + \\ return f"{path}.architect.bak.{timestamp_suffix()}" + \\ + \\def backup_file(path: str) -> str | None: + \\ try: + \\ if os.path.exists(path): + \\ backup = backup_path(path) + \\ shutil.copy2(path, backup) + \\ return backup + \\ except OSError: + \\ return None + \\ return None + \\ + \\def read_text(path: str) -> str | None: + \\ try: + \\ with open(path, "r", encoding="utf-8") as handle: + \\ return handle.read() + \\ except FileNotFoundError: + \\ return None + \\ + \\def write_text(path: str, text: str) -> None: + \\ with open(path, "w", encoding="utf-8") as handle: + \\ handle.write(text) + \\ + \\def load_json(path: str) -> dict | None: + \\ text = read_text(path) + \\ if text is None: + \\ return None + \\ try: + \\ return json.loads(text) + \\ except json.JSONDecodeError: + \\ return None + \\ + \\def write_json(path: str, data: dict) -> None: + \\ write_text(path, json.dumps(data, indent=2, sort_keys=False) + "\n") + \\ + \\def command_has_needle(command: str, needles: tuple[str, ...]) -> bool: + \\ return any(needle in command for needle in needles) + \\ + \\def hooks_have_needles(groups, needles: tuple[str, ...]) -> bool: + \\ if not isinstance(groups, list): + \\ return False + \\ for group in groups: + \\ hooks = group.get("hooks") if isinstance(group, dict) else None + \\ if not isinstance(hooks, list): + \\ continue + \\ for hook in hooks: + \\ if not isinstance(hook, dict): + \\ continue + \\ cmd = hook.get("command") + \\ if isinstance(cmd, str) and command_has_needle(cmd, needles): + \\ return True + \\ return False + \\ + \\def normalize_notify(value) -> list[str] | None: + \\ if not isinstance(value, list): + \\ return None + \\ if not all(isinstance(item, str) for item in value): + \\ return None + \\ return value + \\ + \\def is_architect_notify(command: list[str]) -> bool: + \\ if command == CODEX_NOTIFY: + \\ return True + \\ for item in command: + \\ if "architect_notify.py" in item: + \\ return True + \\ return False + \\ + \\def parse_notify_from_text(text: str) -> list[str] | None: + \\ for line in text.splitlines(): + \\ stripped = line.strip() + \\ if not stripped or stripped.startswith("#"): + \\ continue + \\ if stripped.startswith("notify"): + \\ parts = stripped.split("=", 1) + \\ if len(parts) != 2: + \\ return None + \\ value = parts[1].split("#", 1)[0].strip() + \\ if not value.startswith("[") or not value.endswith("]"): + \\ return None + \\ try: + \\ parsed = json.loads(value) + \\ except json.JSONDecodeError: + \\ return None + \\ return normalize_notify(parsed) + \\ return None + \\ + \\def has_notify_line(text: str) -> bool: + \\ for line in text.splitlines(): + \\ stripped = line.strip() + \\ if not stripped or stripped.startswith("#"): + \\ continue + \\ if stripped.startswith("notify"): + \\ return True + \\ return False + \\ + \\def read_codex_notify(text: str) -> list[str] | None: + \\ if tomllib is not None: + \\ try: + \\ data = tomllib.loads(text) + \\ value = data.get("notify") + \\ parsed = normalize_notify(value) + \\ if parsed is not None: + \\ return parsed + \\ except tomllib.TOMLDecodeError: + \\ return None + \\ return parse_notify_from_text(text) + \\ + \\def ensure_group(groups): + \\ if not groups: + \\ group = {"hooks": []} + \\ groups.append(group) + \\ return group + \\ group = groups[0] + \\ if not isinstance(group, dict): + \\ group = {"hooks": []} + \\ groups[0] = group + \\ if not isinstance(group.get("hooks"), list): + \\ group["hooks"] = [] + \\ return group + \\ + \\def ensure_matcher_group(groups): + \\ if not groups: + \\ group = {"matcher": "*", "hooks": []} + \\ groups.append(group) + \\ return group + \\ for group in groups: + \\ if isinstance(group, dict) and group.get("matcher") == "*": + \\ if not isinstance(group.get("hooks"), list): + \\ group["hooks"] = [] + \\ return group + \\ group = {"matcher": "*", "hooks": []} + \\ groups.append(group) + \\ return group + \\ + \\def ensure_claude_hooks(data: dict) -> bool: + \\ hooks = data.setdefault("hooks", {}) + \\ if not isinstance(hooks, dict): + \\ hooks = {} + \\ data["hooks"] = hooks + \\ stop_groups = hooks.setdefault("Stop", []) + \\ notification_groups = hooks.setdefault("Notification", []) + \\ changed = False + \\ + \\ if not hooks_have_needles(stop_groups, CLAUDE_NEEDLES): + \\ group = ensure_group(stop_groups) + \\ group["hooks"].append({"type": "command", "command": CLAUDE_DONE}) + \\ changed = True + \\ + \\ if not hooks_have_needles(notification_groups, CLAUDE_NEEDLES): + \\ group = ensure_group(notification_groups) + \\ group["hooks"].append({"type": "command", "command": CLAUDE_APPROVAL}) + \\ changed = True + \\ + \\ return changed + \\ + \\def ensure_gemini_hooks(data: dict) -> bool: + \\ hooks = data.setdefault("hooks", {}) + \\ if not isinstance(hooks, dict): + \\ hooks = {} + \\ data["hooks"] = hooks + \\ after_groups = hooks.setdefault("AfterAgent", []) + \\ notification_groups = hooks.setdefault("Notification", []) + \\ changed = False + \\ + \\ if not hooks_have_needles(after_groups, GEMINI_NEEDLES): + \\ group = ensure_matcher_group(after_groups) + \\ group["hooks"].append({ + \\ "name": "architect-completion", + \\ "type": "command", + \\ "command": GEMINI_DONE, + \\ "description": "Notify Architect when task completes", + \\ }) + \\ changed = True + \\ + \\ if not hooks_have_needles(notification_groups, GEMINI_NEEDLES): + \\ group = ensure_matcher_group(notification_groups) + \\ group["hooks"].append({ + \\ "name": "architect-approval", + \\ "type": "command", + \\ "command": GEMINI_APPROVAL, + \\ "description": "Notify Architect when waiting for approval", + \\ }) + \\ changed = True + \\ + \\ tools = data.setdefault("tools", {}) + \\ if not isinstance(tools, dict): + \\ tools = {} + \\ data["tools"] = tools + \\ if tools.get("enableHooks") is not True: + \\ tools["enableHooks"] = True + \\ changed = True + \\ + \\ return changed + \\ + \\ + \\def upsert_notify_line(text: str, line: str) -> str: + \\ lines = text.splitlines() + \\ filtered = [] + \\ for existing in lines: + \\ stripped = existing.strip() + \\ if stripped.startswith("notify") and not stripped.startswith("#"): + \\ continue + \\ filtered.append(existing) + \\ lines = filtered + \\ insert_idx = None + \\ for i, existing in enumerate(lines): + \\ stripped = existing.strip() + \\ if not stripped or stripped.startswith("#"): + \\ continue + \\ if stripped.startswith("["): + \\ insert_idx = i + \\ break + \\ if insert_idx is None: + \\ if lines and lines[-1].strip() != "": + \\ lines.append("") + \\ lines.append(line) + \\ else: + \\ lines.insert(insert_idx, line) + \\ return "\n".join(lines) + "\n" + \\ + \\def install_claude() -> int: + \\ path = os.path.expanduser("~/.claude/settings.json") + \\ data = load_json(path) + \\ if data is None: + \\ print(f"Failed to read {path}", file=sys.stderr) + \\ return 1 + \\ if ensure_claude_hooks(data): + \\ backup = backup_file(path) + \\ if backup: + \\ print(f"Wrote backup to {backup}") + \\ write_json(path, data) + \\ print("Installed Claude hooks.") + \\ else: + \\ print("Claude hooks already installed.") + \\ return 0 + \\ + \\def install_gemini() -> int: + \\ path = os.path.expanduser("~/.gemini/settings.json") + \\ data = load_json(path) + \\ if data is None: + \\ print(f"Failed to read {path}", file=sys.stderr) + \\ return 1 + \\ if ensure_gemini_hooks(data): + \\ backup = backup_file(path) + \\ if backup: + \\ print(f"Wrote backup to {backup}") + \\ write_json(path, data) + \\ print("Installed Gemini hooks.") + \\ else: + \\ print("Gemini hooks already installed.") + \\ return 0 + \\ + \\def install_codex() -> int: + \\ path = os.path.expanduser("~/.codex/config.toml") + \\ text = read_text(path) + \\ if text is None: + \\ print(f"Failed to read {path}", file=sys.stderr) + \\ return 1 + \\ existing = read_codex_notify(text) + \\ if existing is not None and is_architect_notify(existing): + \\ print("Codex hooks already installed.") + \\ return 0 + \\ if existing is not None or has_notify_line(text): + \\ print("Warning: replacing existing Codex notify configuration.", file=sys.stderr) + \\ notify_line = f"notify = {json.dumps(CODEX_NOTIFY)}" + \\ new_text = upsert_notify_line(text, notify_line) + \\ backup = backup_file(path) + \\ if backup: + \\ print(f"Wrote backup to {backup}") + \\ write_text(path, new_text) + \\ print("Installed Codex hooks.") + \\ return 0 + \\ + \\def main() -> int: + \\ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"): + \\ print_usage() + \\ return 1 + \\ + \\ cmd = sys.argv[1] + \\ if cmd == "notify": + \\ raw_arg = sys.argv[2] if len(sys.argv) >= 3 else sys.stdin.read() + \\ if not raw_arg.strip(): + \\ print_usage() + \\ return 1 + \\ state = state_from_notification(raw_arg) + \\ if state is None: + \\ warn_unmapped(raw_arg) + \\ return 0 + \\ notify_architect(state) + \\ return 0 + \\ if cmd == "hook": + \\ if len(sys.argv) < 3: + \\ print_usage() + \\ return 1 + \\ sub = sys.argv[2] + \\ if sub == "claude": + \\ return install_claude() + \\ if sub == "codex": + \\ return install_codex() + \\ if sub == "gemini": + \\ return install_gemini() + \\ print_usage() + \\ return 1 + \\ + \\ print_usage() + \\ return 1 + \\ + \\if __name__ == "__main__": + \\ raise SystemExit(main()) + \\ +; fn setDefaultEnv(name: [:0]const u8, value: [:0]const u8) void { if (posix.getenv(name) != null) return; @@ -161,6 +590,114 @@ pub fn ensureTerminfoSetup() void { } } +fn ensureArchitectCommandSetup() void { + if (architect_command_setup_done) return; + architect_command_setup_done = true; + + const runtime_dir = posix.getenv("XDG_RUNTIME_DIR"); + const home = posix.getenv("HOME"); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const base_dir = if (runtime_dir) |dir| + std.fmt.bufPrint(&base_buf, "{s}/architect", .{dir}) catch |err| { + log.warn("failed to format architect runtime path: {}", .{err}); + return; + } + else if (home) |home_dir| + std.fmt.bufPrint(&base_buf, "{s}/.cache/architect", .{home_dir}) catch |err| { + log.warn("failed to format architect cache path: {}", .{err}); + return; + } + else + "/tmp/architect"; + + std.fs.makeDirAbsolute(base_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => { + log.warn("failed to create architect command base dir: {}", .{err}); + return; + }, + }; + + const bin_dir_z = std.fmt.bufPrintZ(&architect_command_dir_buf, "{s}/bin", .{base_dir}) catch |err| { + log.warn("failed to format architect bin path: {}", .{err}); + return; + }; + const bin_dir = bin_dir_z[0..bin_dir_z.len]; + + std.fs.makeDirAbsolute(bin_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => { + log.warn("failed to create architect bin dir: {}", .{err}); + return; + }, + }; + + const script_path_z = std.fmt.bufPrintZ(&architect_command_path_buf, "{s}/architect", .{bin_dir}) catch |err| { + log.warn("failed to format architect command path: {}", .{err}); + return; + }; + + const script_file = std.fs.createFileAbsolute(script_path_z, .{ .truncate = true }) catch |err| { + log.warn("failed to create architect command: {}", .{err}); + return; + }; + defer script_file.close(); + + script_file.writeAll(architect_command_script) catch |err| { + log.warn("failed to write architect command: {}", .{err}); + return; + }; + + const script_path = std.mem.sliceTo(script_path_z, 0); + posix.fchmodat(posix.AT.FDCWD, script_path, 0o755, 0) catch |err| { + log.warn("failed to chmod architect command: {}", .{err}); + }; + + architect_command_dir_z = bin_dir_z; +} + +fn pathContainsEntry(path: []const u8, entry: []const u8) bool { + var it = std.mem.splitScalar(u8, path, ':'); + while (it.next()) |segment| { + if (std.mem.eql(u8, segment, entry)) return true; + } + return false; +} + +fn ensureArchitectCommandPath() void { + const dir_z = architect_command_dir_z orelse return; + const dir = std.mem.sliceTo(dir_z, 0); + + const path_env = posix.getenv("PATH") orelse ""; + const path_slice = std.mem.sliceTo(path_env, 0); + if (pathContainsEntry(path_slice, dir)) return; + + const needs_sep = path_slice.len > 0; + const sep_len: usize = if (needs_sep) 1 else 0; + const total_len = dir.len + sep_len + path_slice.len; + const buf = std.heap.c_allocator.alloc(u8, total_len + 1) catch |err| { + log.warn("failed to allocate PATH for architect command: {}", .{err}); + return; + }; + defer std.heap.c_allocator.free(buf); + + var idx: usize = 0; + std.mem.copyForwards(u8, buf[idx..][0..dir.len], dir); + idx += dir.len; + if (needs_sep) { + buf[idx] = ':'; + idx += 1; + std.mem.copyForwards(u8, buf[idx..][0..path_slice.len], path_slice); + idx += path_slice.len; + } + buf[idx] = 0; + + const value_z: [:0]u8 = buf[0..idx :0]; + if (libc.setenv("PATH", value_z.ptr, 1) != 0) { + log.warn("failed to set PATH for architect command", .{}); + } +} + fn findExecutableInPath(name: []const u8) ?[:0]const u8 { const path_env = posix.getenv("PATH") orelse return null; const path_env_slice = std.mem.sliceTo(path_env, 0); @@ -193,6 +730,7 @@ pub const Shell = struct { pub fn spawn(shell_path: []const u8, size: pty_mod.winsize, session_id: [:0]const u8, notify_sock: [:0]const u8, working_dir: ?[:0]const u8) SpawnError!Shell { // Ensure terminfo is set up (parent process, before fork) ensureTerminfoSetup(); + ensureArchitectCommandSetup(); const pty_instance = try pty_mod.Pty.open(size); errdefer { @@ -230,6 +768,7 @@ pub const Shell = struct { setDefaultEnv("COLORTERM", default_colorterm); setDefaultEnv("LANG", default_lang); setDefaultEnv("TERM_PROGRAM", default_term_program); + ensureArchitectCommandPath(); // Change to specified directory or home directory before spawning shell. // Try working_dir first, fall back to HOME. @@ -309,3 +848,11 @@ pub const Shell = struct { _ = std.c.waitpid(self.child_pid, null, 0); } }; + +test "pathContainsEntry" { + try std.testing.expect(pathContainsEntry("/usr/bin:/opt/bin", "/usr/bin")); + try std.testing.expect(pathContainsEntry("/usr/bin:/opt/bin", "/opt/bin")); + try std.testing.expect(!pathContainsEntry("/usr/bin:/opt/bin", "/bin")); + try std.testing.expect(pathContainsEntry("/usr/bin", "/usr/bin")); + try std.testing.expect(!pathContainsEntry("", "/usr/bin")); +}