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 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
|