"""Connect agent hosts to Harbor: `harbor install --agent [--project ] [--dry-run|--uninstall]`. Hosts and what gets written (every edited file is backed up first; Harbor's part sits between marker lines so it can be replaced or removed without touching anything else): codex ~/.codex/config.toml [mcp_servers.harbor] (Harbor's MCP tools) ~/.codex/hooks.json SessionStart, UserPromptSubmit, PreToolUse, Stop -> `harbor hook ...` ~/.codex/AGENTS.md the Harbor protocol, applied only inside Harbor projects Codex runs hooks only after you trust them: Settings > Hooks > Trust. opencode ~/.config/opencode/opencode.jsonc "mcp": {"harbor": ...} ~/.config/opencode/plugin/harbor.js STOP gate, session brief, idle nudges ~/.config/opencode/AGENTS.md deepseek ~/.dsh/profiles/desktop/cordis.patch.yml MCP client row + Claude-format hooks bridge row /integrations/deepseek/hooks.json ~/.dsh/AGENTS.md MCP tools hot-reload; the hooks file is read when DeepSeek Harness starts. kimi /integrations/kimi/harbor-/ a Kimi personal plugin (MCP, hooks, system prompt), validated and registered with Kimi's own tools; you install it from Kimi's Plugins > Personal tab. Kimi works outside the project folder, so its plugin is bound to one project. claude prints how to add the Claude Code plugin in integrations/claude-code. Hooks resolve the project from the agent's working directory (or --project when bound), and stay silent in folders that are not Harbor projects, so installing them in a host used for other work is harmless. """ import datetime import json import os import re import shutil import subprocess import sys import tomllib from pathlib import Path from . import projects from .texts import launcher, protocol as agents_section # noqa: F401 PACKAGE_ROOT = projects.PACKAGE_ROOT INTEGRATIONS = PACKAGE_ROOT / "integrations" START, END = "# >>> harbor >>>", "# <<< harbor <<<" MD_START, MD_END = "", "" HOSTS = ("codex", "opencode", "deepseek", "kimi", "claude") DEFAULT_AGENT = {"codex": "Codex", "opencode": "OpenCode", "deepseek": "DeepSeek", "kimi": "Kimi", "claude": "Claude"} class InstallError(Exception): pass def user_home(): return Path(os.environ.get("HARBOR_INSTALL_HOME") or Path.home()) def identity_args(agent, project): return ["--as", agent] + (["--project", project] if project else []) class Plan: """Collects file writes so --dry-run can show them and a real run can back up and apply them.""" def __init__(self, host, dry_run): self.host = host self.dry_run = dry_run self.writes = [] self.notes = [] self.stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") def write(self, path, text, validate=None): if validate and text is not None: validate(text) self.writes.append((Path(path), text)) def remove(self, path): self.writes.append((Path(path), None)) def apply(self): report = [] for path, text in self.writes: old = path.read_text(encoding="utf-8") if path.exists() else None if old == text: report.append(f"unchanged {path}") continue action = "remove" if text is None else ("update" if old is not None else "create") report.append(f"{action:<10} {path}") if self.dry_run: continue if old is not None: backup = projects.harbor_home() / "backups" / "integrations" / self.stamp / self.host / path.name backup.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, backup) report[-1] += f" (backup: {backup})" if text is None: path.unlink() else: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8", newline="\n") return report + self.notes def replace_block(text, block, start=START, end=END): """Put block between the markers (replacing an earlier Harbor block); block=None removes it.""" pattern = re.compile(re.escape(start) + r".*?" + re.escape(end) + r"\n?", re.S) text = pattern.sub("", text or "") if block is None: return text.rstrip("\n") + "\n" if text.strip() else "" return (text.rstrip("\n") + "\n\n" if text.strip() else "") + f"{start}\n{block.rstrip()}\n{end}\n" def _hook_entries(cmd, agent, project, host, events): base = " ".join(cmd + identity_args(agent, project) + ["hook"]) names = {"SessionStart": "session-start", "UserPromptSubmit": "prompt-submit", "PreToolUse": "pre-tool", "Stop": "stop"} return {event: [{"hooks": [{"type": "command", "command": f"{base} {names[event]} --host {host}", "timeout": 30}]}] for event in events} def _is_ours(entry): return "run.py" in json.dumps(entry) and " hook " in json.dumps(entry) def merge_hooks_json(text, ours, remove=False): data = json.loads(text) if text and text.strip() else {} hooks = data.setdefault("hooks", {}) for event in list(hooks): hooks[event] = [e for e in hooks[event] if not _is_ours(e)] if not hooks[event]: del hooks[event] if not remove: for event, entries in ours.items(): hooks.setdefault(event, []).extend(entries) if not hooks: data.pop("hooks") return json.dumps(data, indent=2) + "\n" def _read(path): return path.read_text(encoding="utf-8") if path.exists() else "" def _md_block(path, section, remove): return replace_block(_read(path), None if remove else section, MD_START, MD_END) # ----------------------------------------------------------------------------------------------- hosts def install_codex(plan, agent, project, cmd, remove): home = user_home() / ".codex" config = home / "config.toml" text = _read(config) outside = replace_block(text, None) if re.search(r"^\[mcp_servers\.harbor\]", outside, re.M): raise InstallError(f"{config} already defines [mcp_servers.harbor] outside Harbor's block; remove it first") if re.search(r"^\[hooks\]", outside, re.M): raise InstallError(f"{config} has a [hooks] table; Codex refuses hooks in both config.toml and hooks.json") args = cmd[1:] + identity_args(agent, project) + ["mcp"] env = {"HARBOR_AGENT": agent, "HARBOR_HOST": "codex", **({"HARBOR_PROJECT": project} if project else {})} block = (f"# Managed by `harbor install codex`; remove with `harbor install codex --uninstall`.\n" f"[mcp_servers.harbor]\ncommand = {json.dumps(cmd[0])}\nargs = {json.dumps(args)}\n" f"startup_timeout_sec = 30\ntool_timeout_sec = 1900\n\n[mcp_servers.harbor.env]\n" + "".join(f"{k} = {json.dumps(v)}\n" for k, v in env.items())) plan.write(config, replace_block(text, None if remove else block), validate=tomllib.loads) hooks_path = home / "hooks.json" ours = _hook_entries(cmd, agent, project, "codex", ("SessionStart", "UserPromptSubmit", "PreToolUse", "Stop")) merged = merge_hooks_json(_read(hooks_path), ours, remove=remove) plan.write(hooks_path, merged if json.loads(merged) else None, validate=json.loads) plan.write(home / "AGENTS.md", _md_block(home / "AGENTS.md", agents_section(agent, project, cmd), remove)) if not remove: plan.notes.append("Codex: open Settings > Hooks and trust the Harbor hooks (until then Codex skips them). " "The MCP tools load in new Codex threads.") def _jsonc_load(text): if re.search(r"(^|\s)//|/\*", re.sub(r'"(?:\\.|[^"\\])*"', '""', text)): raise InstallError("opencode.jsonc has comments; add the \"mcp\" entry by hand (see `--dry-run` output)") return json.loads(text) if text.strip() else {} def install_opencode(plan, agent, project, cmd, remove): home = user_home() / ".config" / "opencode" config = next((p for p in (home / "opencode.jsonc", home / "opencode.json") if p.exists()), home / "opencode.jsonc") data = _jsonc_load(_read(config)) mcp = data.setdefault("mcp", {}) mcp.pop("harbor", None) if not remove: env = {"HARBOR_AGENT": agent, "HARBOR_HOST": "opencode", **({"HARBOR_PROJECT": project} if project else {})} mcp["harbor"] = {"type": "local", "command": cmd + identity_args(agent, project) + ["mcp"], "environment": env, "enabled": True, "timeout": 15000} if not mcp: data.pop("mcp") plan.write(config, json.dumps(data, indent=2) + "\n", validate=json.loads) plugin = home / "plugin" / "harbor.js" if remove: plan.remove(plugin) if plugin.exists() else None else: source = (INTEGRATIONS / "opencode" / "harbor.js").read_text(encoding="utf-8") settings = (f"const AGENT = {json.dumps(agent)};\nconst PROJECT = {json.dumps(project)};\n" f"const COMMAND = {json.dumps(cmd)};\nconst WAKE_IDLE_SESSIONS = true; // nudge idle sessions " "when messages arrive (never while stopped)\n") source = re.sub(r"(// >>> harbor settings >>>\n).*?(// <<< harbor settings <<<)", lambda m: m.group(1) + settings + m.group(2), source, flags=re.S) plan.write(plugin, source) plan.write(home / "AGENTS.md", _md_block(home / "AGENTS.md", agents_section(agent, project, cmd), remove)) if not remove: plan.notes.append("OpenCode: restart OpenCode to load the plugin and the MCP server.") class _TolerantLoader: @staticmethod def load(text): import yaml class Loader(yaml.SafeLoader): pass Loader.add_multi_constructor("", lambda loader, suffix, node: None) Loader.add_multi_constructor("tag:yaml.org,2002:js", lambda loader, suffix, node: None) value = yaml.load(text, Loader=Loader) if not isinstance(value, list): raise InstallError("cordis.patch.yml must stay a YAML list") return value def install_deepseek(plan, agent, project, cmd, remove): home = user_home() / ".dsh" patch = home / "profiles" / "desktop" / "cordis.patch.yml" if not patch.exists() and not remove: raise InstallError(f"{patch} not found: is DeepSeek Harness set up for this user?") hooks_path = projects.harbor_home() / "integrations" / "deepseek" / "hooks.json" args = cmd[1:] + identity_args(agent, project) + ["mcp"] env = {"HARBOR_AGENT": agent, "HARBOR_HOST": "deepseek", **({"HARBOR_PROJECT": project} if project else {})} q = lambda s: "'" + str(s).replace("'", "''") + "'" block = ("# Managed by `harbor install deepseek`: Harbor's MCP tools and hooks.\n- insert:\n" " - id: mcp-harbor\n name: '@deepseek-ai/dsh-mcp-client'\n config:\n" f" serverName: harbor\n transport: stdio\n command: {q(cmd[0])}\n" f" args: [{', '.join(q(a) for a in args)}]\n" f" env: {{{', '.join(f'{k}: {q(v)}' for k, v in env.items())}}}\n" " toolCallTimeoutMs: 1900000\n" " - id: hooks-harbor\n name: '@deepseek-ai/dsh-hooks-claude-code'\n config:\n" f" configPath: {q(hooks_path.as_posix())}\n defaultTimeoutMs: 30000\n") text = replace_block(_read(patch), None if remove else block) plan.write(patch, text if text.strip() else "[]\n", validate=_TolerantLoader.load) if remove: plan.remove(hooks_path) if hooks_path.exists() else None else: ours = _hook_entries(cmd, agent, project, "deepseek", ("SessionStart", "UserPromptSubmit", "PreToolUse", "Stop")) plan.write(hooks_path, json.dumps({"hooks": ours}, indent=2) + "\n", validate=json.loads) plan.write(home / "AGENTS.md", _md_block(home / "AGENTS.md", agents_section(agent, project, cmd), remove)) if not remove: plan.notes.append("DeepSeek Harness: the MCP tools hot-reload; restart the app once to load the hooks file.") def kimi_share(): return Path(os.environ.get("KIMI_SHARE_DIR") or Path(os.environ.get("APPDATA", user_home())) / "kimi-desktop" / "daimon-share") def install_kimi(plan, agent, project, cmd, remove): if not project: project = projects.resolve().name folder = projects.harbor_home() / "integrations" / "kimi" / f"harbor-{project}" name = f"harbor-{project}" if remove: for path in sorted(folder.rglob("*"), reverse=True) if folder.exists() else []: if path.is_file(): plan.remove(path) plan.notes.append(f"Kimi: uninstall '{name}' from Plugins > Personal in Kimi as well.") return base = " ".join(cmd + identity_args(agent, project) + ["hook"]) title = projects.load_named(project).title manifest = { "$schema": "https://catalog.msh.team/misc/kimi.plugin.schema.json", "name": name, "version": "1.3.0", "description": f"Connects Kimi to the Harbor team hub for {title}: messages, acknowledgements, claims, the " "review queue and the stop switch shared with the other agents.", "keywords": ["harbor", "team", "coordination"], "author": "Harbor", "license": "MIT", "skillInstructions": "Use the Harbor tools for all team communication on this project.", "interface": { "displayName": f"Harbor: {title}", "shortDescription": f"Team coordination for {title}", "longDescription": f"Harbor tools, session brief, stop gate and message nudges for {title}.", "developerName": "Harbor", "websiteURL": "", "iconUrl": "", "category": "PRODUCTIVITY", "defaultLocale": "en-US", "locales": "./locales/", "tryPrompts": ["Check Harbor for messages", "What is the team doing right now?"], "skillOverrides": {name: {"displayName": f"Harbor: {title}", "description": f"Team coordination for {title}"}}, "hostKind": "local", "platforms": ["windows", "macos", "linux"], "mcpOverrides": {"harbor": {"displayName": "Harbor", "description": f"Team coordination for {title}"}}, }, "skills": "./skills/", "mcpServers": {"harbor": {"command": cmd[0], "args": cmd[1:] + identity_args(agent, project) + ["mcp"], "env": {"HARBOR_AGENT": agent, "HARBOR_PROJECT": project, "HARBOR_HOST": "kimi"}, "toolTimeoutMs": 1900000}}, "systemPromptPath": "./system-prompt.md", "hooks": [ {"event": "UserPromptSubmit", "command": f"{base} prompt-submit --host kimi", "timeout": 30}, {"event": "PreToolUse", "command": f"{base} pre-tool --host kimi", "timeout": 30}, {"event": "Stop", "command": f"{base} stop --host kimi", "timeout": 30}, ], } zh = {"description": f"把 Kimi 接入 {title} 的 Harbor 团队协作:消息、确认、认领、评审队列和暂停开关。", "interface": {"displayName": f"Harbor:{title}", "shortDescription": f"{title} 的团队协作", "longDescription": f"{title} 的 Harbor 工具、会话简报、暂停闸门和消息提醒。", "tryPrompts": ["查看 Harbor 消息", "团队现在在做什么?"], "skillOverrides": {name: {"displayName": f"Harbor:{title}", "description": f"{title} 的团队协作"}}, "mcpOverrides": {"harbor": {"displayName": "Harbor", "description": f"{title} 的团队协作"}}}} plan.write(folder / "kimi.plugin.json", json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", validate=json.loads) plan.write(folder / "locales" / "zh-CN.json", json.dumps(zh, indent=2, ensure_ascii=False) + "\n", validate=json.loads) plan.write(folder / "system-prompt.md", agents_section(agent, project, cmd)) plan.write(folder / "skills" / name / "SKILL.md", f"---\nname: {name}\ndescription: Team coordination for {title} through Harbor (messages, claims, " "review queue, stop switch). Use at the start of every session and whenever you talk to the team.\n---\n\n" + agents_section(agent, project, cmd)) plan.notes.append(f"Kimi: plugin folder {folder}. After it is registered, install it from Kimi's Plugins > " "Personal tab (Kimi asks you to review plugins that carry hooks).") plan.kimi_folder = folder def kimi_register(folder, dry_run): scripts = kimi_share() / "daimon" / "skills" / "plugin-builder" / "scripts" daimon = Path(os.environ.get("APPDATA", "")) / "kimi-desktop" / "daimon-bundle" / "bin" / "kimi-daimon.cmd" if not (scripts / "register_personal.py").exists() or not daimon.exists(): return "Kimi: its plugin tools were not found; ask Kimi to register the plugin folder (plugin-builder skill)." validate = subprocess.run([sys.executable, str(scripts / "validate_plugin.py"), str(folder)], capture_output=True, text=True, encoding="utf-8", errors="replace") errors = [line for line in validate.stdout.splitlines() + validate.stderr.splitlines() if "[ERROR]" in line] if errors: return "Kimi validation failed:\n " + "\n ".join(errors) if dry_run: return "Kimi: plugin validates; a real run registers it in the Personal market." r = subprocess.run([sys.executable, str(scripts / "register_personal.py"), "--share-dir", str(kimi_share()), "--daimon-bin", str(daimon), str(folder)], capture_output=True, text=True, encoding="utf-8", errors="replace") return ("Kimi: registered in the Personal market. " if r.returncode == 0 else f"Kimi registration failed (exit {r.returncode}): ") + (r.stdout.strip() or r.stderr.strip())[-600:] def install_claude(plan, agent, project, cmd, remove): """A local Claude Code marketplace holding the Harbor plugin (MCP server, hooks, skill).""" market = projects.harbor_home() / "integrations" / "claude" plugin = market / "harbor" if remove: for path in sorted(market.rglob("*"), reverse=True) if market.exists() else []: if path.is_file(): plan.remove(path) plan.notes.append("Claude Code: also run `/plugin uninstall harbor@harbor-local`.") return ident = ["--project", project] if project else [] base = " ".join(cmd + ident + ["hook"]) hooks = {event: [{"hooks": [{"type": "command", "command": f"{base} {name} --host claude", "timeout": 30}]}] for event, name in (("SessionStart", "session-start"), ("UserPromptSubmit", "prompt-submit"), ("PreToolUse", "pre-tool"), ("Stop", "stop"))} dump = lambda value: json.dumps(value, indent=2) + "\n" plan.write(market / ".claude-plugin" / "marketplace.json", dump({ "name": "harbor-local", "owner": {"name": "Harbor"}, "plugins": [{"name": "harbor", "source": "./harbor", "description": "Harbor team coordination: MCP tools, session brief, stop gate, message nudges"}]}), validate=json.loads) plan.write(plugin / ".claude-plugin" / "plugin.json", dump({ "name": "harbor", "version": "2.0.0", "author": {"name": "Harbor"}, "description": "Harbor team coordination: MCP tools, session brief, stop gate and message nudges. The agent " "identity comes from the HARBOR_AGENT environment variable."}), validate=json.loads) plan.write(plugin / "hooks" / "hooks.json", dump({"hooks": hooks}), validate=json.loads) env = {"HARBOR_AGENT": "${HARBOR_AGENT:-" + agent + "}", "HARBOR_HOST": "claude", **({"HARBOR_PROJECT": project} if project else {})} plan.write(plugin / ".mcp.json", dump({"mcpServers": {"harbor": {"command": cmd[0], "args": cmd[1:] + ident + ["mcp"], "env": env}}}), validate=json.loads) plan.write(plugin / "skills" / "harbor" / "SKILL.md", "---\nname: harbor\ndescription: Team coordination through Harbor (messages, claims, review queue, " "stop switch). Use at the start of a session in a Harbor project and whenever you talk to the team.\n" "---\n\n" + agents_section("the agent named by HARBOR_AGENT", project, cmd)) settings = json.dumps({"env": {"HARBOR_AGENT": agent}}) plan.notes.append(f"Claude Code: run `/plugin marketplace add {market.as_posix()}` then `/plugin install " f"harbor@harbor-local`, and set HARBOR_AGENT for the session, for example {settings} in " ".claude/settings.json.") def install(host, agent=None, project=None, dry_run=False, remove=False, python=None, register=True): if host not in HOSTS: raise InstallError(f"host must be one of {', '.join(HOSTS)}") agent = agent or DEFAULT_AGENT[host] if project: p = projects.load_named(project) else: try: p = projects.resolve() except projects.ProjectError: p = None if p and agent not in p.ai_names and not remove: # an agent that left the roster can still be uninstalled raise InstallError(f"{agent} is not an agent of project {p.name} ({', '.join(p.ai_names)})") cmd = launcher(python) plan = Plan(host, dry_run) {"codex": install_codex, "opencode": install_opencode, "deepseek": install_deepseek, "kimi": install_kimi, "claude": install_claude}[host](plan, agent, project, cmd, remove) report = plan.apply() if host == "kimi" and not remove and getattr(plan, "kimi_folder", None): if dry_run: report.append("Kimi: a real run validates the plugin with Kimi's validator and registers it.") elif register: report.append(kimi_register(plan.kimi_folder, dry_run)) return report