diff --git a/apps/mac/AIWorkspace/README.md b/apps/mac/AIWorkspace/README.md index a5631f3..da01dc0 100644 --- a/apps/mac/AIWorkspace/README.md +++ b/apps/mac/AIWorkspace/README.md @@ -28,6 +28,21 @@ Install to `~/Applications/AIWorkspace.app`: apps/mac/AIWorkspace/scripts/package-app.sh --install ``` +The script defaults to the per-user install location because this workspace is a +developer-local utility and frequent rebuilds should not require `sudo`. To +install system-wide instead, pass an explicit install directory: + +```bash +INSTALL_DIR=/Applications apps/mac/AIWorkspace/scripts/package-app.sh --install +APP_PATH=/Applications/AIWorkspace.app apps/mac/AIWorkspace/scripts/install-start-at-login.sh +``` + +Avoid keeping both `~/Applications/AIWorkspace.app` and +`/Applications/AIWorkspace.app` installed unless you are intentionally comparing +builds. They share the same bundle identifier (`com.aiworkspace.menu`) and can +confuse LaunchServices, login item registration, and which copy opens at login. +If both exist, quit any running AI Workspace instances and remove the stale copy. + ## Build a DMG ```bash diff --git a/apps/mac/AIWorkspace/Sources/main.swift b/apps/mac/AIWorkspace/Sources/main.swift index 7db94b0..f1c63f7 100644 --- a/apps/mac/AIWorkspace/Sources/main.swift +++ b/apps/mac/AIWorkspace/Sources/main.swift @@ -4,11 +4,51 @@ import ServiceManagement import SwiftUI private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true) -private let defaultProfile = "fidelity" + +struct ProfileConfig { + let id: String + let displayName: String + let knowledgeDir: String + + static func discoverDefault() -> ProfileConfig { + let profilesRoot = workspaceRoot.appendingPathComponent("profiles", isDirectory: true) + let candidates = (try? FileManager.default.contentsOfDirectory(at: profilesRoot, includingPropertiesForKeys: nil)) ?? [] + let configs = candidates.compactMap { directory -> ProfileConfig? in + let configURL = directory.appendingPathComponent("workspace.json") + guard let data = try? Data(contentsOf: configURL), + let decoded = try? JSONDecoder().decode(ProfileWorkspaceConfig.self, from: data) + else { return nil } + return ProfileConfig( + id: decoded.profile ?? directory.lastPathComponent, + displayName: decoded.displayName ?? decoded.profile ?? directory.lastPathComponent, + knowledgeDir: decoded.knowledgeDir ?? "workspaces/\(decoded.profile ?? directory.lastPathComponent)/project-knowledge" + ) + } + if let fidelity = configs.first(where: { $0.id == "fidelity" }) { + return fidelity + } + if let first = configs.sorted(by: { $0.id < $1.id }).first { + return first + } + return ProfileConfig(id: "fidelity", displayName: "Fidelity", knowledgeDir: "workspaces/fidelity/project-knowledge") + } +} + +private struct ProfileWorkspaceConfig: Decodable { + let profile: String? + let displayName: String? + let knowledgeDir: String? + + enum CodingKeys: String, CodingKey { + case profile + case displayName = "display_name" + case knowledgeDir = "knowledge_dir" + } +} @main struct AIWorkspaceApp: App { - @StateObject private var model = ServiceStatusModel(profile: defaultProfile) + @StateObject private var model = ServiceStatusModel() var body: some Scene { MenuBarExtra { @@ -30,11 +70,12 @@ final class ServiceStatusModel: ObservableObject { @Published private(set) var lanIP: String? @Published private(set) var loginItemStatus: SMAppService.Status = .notRegistered @Published private(set) var isRefreshing = false + @Published private(set) var activeAction: String? - let profile: String + let profile: ProfileConfig - init(profile: String) { - self.profile = profile + init() { + self.profile = ProfileConfig.discoverDefault() } var menuBarSymbol: String { @@ -52,7 +93,7 @@ final class ServiceStatusModel: ObservableObject { isRefreshing = true defer { isRefreshing = false } do { - let data = try await ServiceManager.run(["status", "--profile", profile, "--json"]) + let data = try await ServiceManager.run(["status", "--profile", profile.id, "--json"]) report = try JSONDecoder().decode(StatusReport.self, from: data) lanIP = await NetworkInfo.primaryLANIP() loginItemStatus = SMAppService.mainApp.status @@ -92,25 +133,33 @@ final class ServiceStatusModel: ObservableObject { } func startProfile() { - runAction(["start", "--profile", profile]) + runAction("Starting services…", ["start", "--profile", profile.id]) } func stopProfile() { - runAction(["stop", "--profile", profile]) + runAction("Stopping services…", ["stop", "--profile", profile.id]) + } + + func primaryServiceAction() { + if allManagedServicesRunning { + stopProfile() + } else { + startProfile() + } } func restartMCP() { - runAction(["restart", "aiw-context-mcp", "--profile", profile]) + runAction("Restarting MCP…", ["restart", "aiw-context-mcp", "--profile", profile.id]) } func runDoctor() { - runAction(["doctor", "--profile", profile]) + runAction("Running doctor…", ["doctor", "--profile", profile.id]) } func copyDoctorJSON() { Task { do { - let data = try await ServiceManager.run(["doctor", "--profile", profile, "--json"]) + let data = try await ServiceManager.run(["doctor", "--profile", profile.id, "--json"]) copyToPasteboard(String(data: data, encoding: .utf8) ?? "") } catch { lastError = String(describing: error) @@ -128,7 +177,7 @@ final class ServiceStatusModel: ObservableObject { do { var chunks: [String] = [] for service in report?.services ?? [] { - let data = try await ServiceManager.run(["logs", service.name, "--profile", profile, "--lines", "30"]) + let data = try await ServiceManager.run(["logs", service.name, "--profile", profile.id, "--lines", "30"]) if let text = String(data: data, encoding: .utf8), !text.isEmpty { chunks.append(text) } @@ -147,15 +196,33 @@ final class ServiceStatusModel: ObservableObject { } func openLogsFolder() { - NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(".aiw/runtime/logs", isDirectory: true)) + NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(".aiw/runtime/logs/\(profile.id)", isDirectory: true)) } func openProjectKnowledge() { - NSWorkspace.shared.open(workspaceRoot.appendingPathComponent("project-knowledge", isDirectory: true)) + NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(profile.knowledgeDir, isDirectory: true)) } func openMattermost() { - runAction(["start", "mattermost-desktop", "--profile", profile]) + runAction("Launching Mattermost…", ["start", "mattermost-desktop", "--profile", profile.id]) + } + + var allManagedServicesRunning: Bool { + guard let services = report?.services else { return false } + let managed = services.filter { $0.enabled && $0.kind != "app-launcher" } + return !managed.isEmpty && managed.allSatisfy { $0.status == "running" || $0.status == "externally running" } + } + + var primaryActionTitle: String { + allManagedServicesRunning ? "Stop Services" : "Start Services" + } + + var primaryActionSymbol: String { + allManagedServicesRunning ? "stop.fill" : "play.fill" + } + + var isBusy: Bool { + isRefreshing || activeAction != nil } private func copyToPasteboard(_ value: String) { @@ -163,8 +230,10 @@ final class ServiceStatusModel: ObservableObject { NSPasteboard.general.setString(value, forType: .string) } - private func runAction(_ arguments: [String]) { + private func runAction(_ label: String, _ arguments: [String]) { Task { + activeAction = label + defer { activeAction = nil } do { _ = try await ServiceManager.run(arguments) await refresh() @@ -184,20 +253,31 @@ struct ServiceMenuView: View { VStack(alignment: .leading, spacing: 3) { Text("AI Workspace") .font(.title3.bold()) - Text("Profile: \(model.profile)") + Text("Profile: \(model.profile.displayName)") .font(.caption) .foregroundStyle(.secondary) } Spacer() + if model.isBusy { + ProgressView() + .controlSize(.small) + } Button { Task { await model.refresh() } } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) + .disabled(model.isBusy) .help("Refresh") } + if let activeAction = model.activeAction { + Label(activeAction, systemImage: "hourglass") + .font(.caption) + .foregroundStyle(.secondary) + } + Divider() if let report = model.report { @@ -231,20 +311,26 @@ struct ServiceMenuView: View { Divider() + Button(role: model.allManagedServicesRunning ? .destructive : nil, action: model.primaryServiceAction) { + Label(model.primaryActionTitle, systemImage: model.primaryActionSymbol) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(model.isBusy) + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { - ActionButton(title: "Start Fidelity", systemImage: "play.fill", action: model.startProfile) - ActionButton(title: "Stop Fidelity", systemImage: "stop.fill", role: .destructive, action: model.stopProfile) - ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", action: model.restartMCP) - ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", action: model.openMattermost) + ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", disabled: model.isBusy, action: model.restartMCP) + ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", disabled: model.isBusy, action: model.openMattermost) } Divider() LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { - ActionButton(title: "Run Doctor", systemImage: "stethoscope", action: model.runDoctor) - ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", action: model.copyDoctorJSON) + ActionButton(title: "Run Doctor", systemImage: "stethoscope", disabled: model.isBusy, action: model.runDoctor) + ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", disabled: model.isBusy, action: model.copyDoctorJSON) ActionButton(title: "Copy Photo URL", systemImage: "link", action: model.copyPhotoInboxURL) - ActionButton(title: "Copy Logs", systemImage: "doc.text", action: model.copyRecentLogs) + ActionButton(title: "Copy Logs", systemImage: "doc.text", disabled: model.isBusy, action: model.copyRecentLogs) ActionButton(title: "MCP Health", systemImage: "heart.text.square", action: model.openMCPHealth) ActionButton(title: "Logs Folder", systemImage: "folder", action: model.openLogsFolder) } @@ -290,6 +376,7 @@ struct ActionButton: View { let title: String let systemImage: String var role: ButtonRole? + var disabled = false let action: () -> Void var body: some View { @@ -299,6 +386,7 @@ struct ActionButton: View { } .buttonStyle(.bordered) .controlSize(.small) + .disabled(disabled) } } @@ -369,6 +457,15 @@ enum ServiceManager { process.currentDirectoryURL = workspaceRoot process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = ["python3", "scripts/aiw/services.py"] + arguments + var environment = ProcessInfo.processInfo.environment + let guiSafePATH = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin" + if let existingPATH = environment["PATH"], !existingPATH.isEmpty { + environment["PATH"] = "\(guiSafePATH):\(existingPATH)" + } else { + environment["PATH"] = guiSafePATH + } + environment["AIW_WORKSPACE_ROOT"] = workspaceRoot.path + process.environment = environment let output = Pipe() let error = Pipe() diff --git a/scripts/aiw/services.py b/scripts/aiw/services.py index 33c38eb..743a0c6 100644 --- a/scripts/aiw/services.py +++ b/scripts/aiw/services.py @@ -31,6 +31,16 @@ STATE_DIR = RUNTIME_DIR / "state" DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024 DEFAULT_LOG_BACKUPS = 3 DEFAULT_STOP_TIMEOUT_SECONDS = 5.0 +DEFAULT_SERVICE_PATHS = [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", +] @dataclass(frozen=True) @@ -126,14 +136,35 @@ def resolve_workspace_path(raw: str) -> Path: return path if path.is_absolute() else ROOT / path -def command_exists(command: str) -> bool: +def service_env(profile: str | None = None) -> dict[str, str]: + """Return a robust environment for services launched from shells or GUI apps. + + macOS login items do not reliably inherit the user's interactive shell PATH. + Homebrew-installed tools such as mitmdump commonly live under /opt/homebrew/bin + or /usr/local/bin, so make those paths available to both direct service + commands and nested scripts. + """ + env = os.environ.copy() + existing = [part for part in env.get("PATH", "").split(os.pathsep) if part] + merged: list[str] = [] + for part in [*existing, *DEFAULT_SERVICE_PATHS]: + if part not in merged: + merged.append(part) + env["PATH"] = os.pathsep.join(merged) + env.setdefault("AIW_WORKSPACE_ROOT", str(ROOT)) + if profile: + env.setdefault("AIW_PROJECT_PROFILE", profile) + return env + + +def command_exists(command: str, env: dict[str, str] | None = None) -> bool: if not command: return False path = Path(command) if path.is_absolute() or "/" in command: resolved = resolve_workspace_path(command) return resolved.exists() and os.access(resolved, os.X_OK) - return shutil_which(command) is not None + return shutil_which(command, env=env) is not None def rotate_log_if_needed(path: Path, max_bytes: int = DEFAULT_LOG_MAX_BYTES, backups: int = DEFAULT_LOG_BACKUPS) -> None: @@ -273,9 +304,10 @@ def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], start kind = ref.config.get("kind", "process") command = ref.config.get("command") or [] + env = service_env(profile) if not command: raise SystemExit(f"{ref.name} has no command") - if not command_exists(str(command[0])): + if not command_exists(str(command[0]), env=env): raise SystemExit(f"{ref.name} command is not executable or not found: {command[0]}") if kind != "app-launcher": @@ -298,11 +330,11 @@ def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], start with path.open("ab") as log_file: log_file.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode("utf-8")) if kind == "app-launcher": - result = subprocess.run(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, check=False) + result = subprocess.run(command, cwd=ROOT, env=env, stdout=log_file, stderr=subprocess.STDOUT, check=False) write_state(profile, ref.name, {"last_launch_exit": result.returncode, "launched_at": time.time()}) print(f"{ref.name}: launched (exit {result.returncode})") else: - process = subprocess.Popen(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True) + process = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True) pid_file = pid_path(profile, ref.name) pid_file.parent.mkdir(parents=True, exist_ok=True) pid_file.write_text(str(process.pid) + "\n", encoding="utf-8") @@ -385,6 +417,7 @@ def tail_log(profile: str, service: str, lines: int) -> None: def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]: errors = validate_manifest(manifest) + env = service_env(profile) service_reports = [] for ref in service_items(manifest, include_disabled=True): command = ref.config.get("command") or [] @@ -392,15 +425,15 @@ def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]: doctor = ref.config.get("doctor") or {} checks = [] for command_name in doctor.get("required_commands") or []: - checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name)}) + checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name, env=env)}) for command_name in doctor.get("optional_commands") or []: - checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name)}) + checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name, env=env)}) for raw_path in doctor.get("required_paths") or []: checks.append({"type": "required_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()}) for raw_path in doctor.get("optional_paths") or []: checks.append({"type": "optional_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()}) status = service_status(profile, ref) - status["command_ok"] = command_exists(first) if first else False + status["command_ok"] = command_exists(first, env=env) if first else False status["checks"] = checks service_reports.append(status) return { @@ -443,8 +476,8 @@ def run_doctor(profile: str, manifest: dict[str, Any], json_output: bool = False print(f" {label} {check['name']}: {'ok' if check['ok'] else 'missing'}") -def shutil_which(command: str) -> str | None: - paths = os.environ.get("PATH", "").split(os.pathsep) +def shutil_which(command: str, env: dict[str, str] | None = None) -> str | None: + paths = (env or os.environ).get("PATH", "").split(os.pathsep) for directory in paths: candidate = Path(directory) / command if candidate.exists() and os.access(candidate, os.X_OK):