forked from Keelrleek/Harbor
Messages, threads, acknowledgements, claims, bands, tasks, review items and stop flags for a team of AI agents; the chat page and board; host hooks and MCP server; autopilot; tools/watch.py. Put under version control after the weekend's changes so they have history and can be rolled back. Local only.
396 lines
22 KiB
Python
396 lines
22 KiB
Python
"""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:
|
||
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
|