Files

396 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Connect agent hosts to Harbor: `harbor install <host> --agent <Name> [--project <name>] [--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
<harbor home>/integrations/deepseek/hooks.json
~/.dsh/AGENTS.md
MCP tools hot-reload; the hooks file is read when DeepSeek Harness starts.
kimi <harbor home>/integrations/kimi/harbor-<project>/ 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 = "<!-- >>> harbor >>> -->", "<!-- <<< harbor <<< -->"
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