gemini-notebook-mcp-cli 引导式 `nlm setup` 向导解析:为 AI 工具安全配置 MCP 与 Skill 的完整实现
gemini-notebook-mcp-cli 引导式 nlm setup 向导解析:为 AI 工具安全配置 MCP 与 Skill 的完整实现
本文基于仓库内实现计划 2026-09-27-guided-setup.md 与其配套设计文档 2026-09-27-setup-wizard-design.md,结合 setup_safety.py、setup.py、setup_wizard.py、skill.py 等源码展开。它解答的是:为什么直接运行
nlm setup就能进入一个不触碰 NotebookLM API、却能安全完成多工具 MCP 接入与 Skill 安装/移除的交互式向导;以及这套“先备份、后写入、失败即停”的本地配置工程是如何在源码层面落地的。
一文看懂:目标、边界与总体架构
引导式 nlm setup 的核心目标非常克制:让刚接触本项目的用户不用记住任何配置文件的格式与路径,只认识自己正在用的 AI 应用,就能完成 MCP 接入和可选 Skill 安装。根据设计文档,裸执行 nlm setup(不带子命令)打开一个终端向导,提供添加、移除、复制 MCP 配置三大路径;原有的 nlm setup add/remove/list 与 nlm skill 命令全部保留。
三条硬边界贯穿整个实现:
- 完全离线:向导是本地配置操作,不校验 NotebookLM Cookie、不调用 NotebookLM API、不启动 MCP 服务器;
- 只动自己的东西:只移除被识别的 NLM 条目与精确匹配的 NLM Skill 目录,绝不触碰无关配置;
- 改动必须可回滚:任何对既有配置或 Skill 目录的变更前,先在备份根目录创建私有备份;备份或解析失败,则该目标原样保留。
架构上,实现计划给出的分层是:保留 setup.py 与 skill.py 作为既有客户端适配器,新增一个小的配置安全模块(setup_safety.py)和一个独立的向导模块(setup_wizard.py),并改进 Codex、GitHub Copilot 适配器以贴合当前客户端行为。向导只负责编排:调用适配器、为每个目标记录结果、汇总展示。技术栈为 Python 3.11+、Typer、Rich、questionary、tomlkit、pytest、uv(见 pyproject.toml 中的 requires-python = ">=3.11" 及 rich、tomlkit、packaging、questionary 依赖声明)。
入口与交互契约:非交互终端退出 1,用户取消退出 130
裸 nlm setup 的入口实现有两个关键点(源码见 setup.py 与 setup.py):
- Typer 应用显式设置
no_args_is_help=False,使“无子命令”不会直接弹帮助; @app.callback(invoke_without_command=True)在未调用任何子命令时才把控制权交给run_setup_wizard(),--help与既有子命令行为不受影响。
向导会先做 TTY 检查(见 setup_wizard.py 的 is_interactive(),要求 stdin 与 stdout 均为 isatty()):
- 非交互终端(管道、CI、脚本调用):打印显式命令提示(
nlm setup add <client>、nlm setup remove <client>、nlm setup list、nlm skill install <tool>),不做任何文件修改,退出码为 1; - 用户取消(主菜单按 Esc 或 Ctrl+C):立即停止剩余动作、清理临时文件、汇总此前已完成的改动,退出码为 130(128+SIGINT);
- 单个工具失败不阻塞其他工具:每个选中的目标单独产出结果,最终汇总区分
configured / already / repaired / skipped / failed / partial等状态。
向导主菜单(setup_wizard.py)比原始计划多演化出若干门:Show my tools' status(状态总览表)、Add the MCP to my tools/agents、Add the skill to my tools/agents、Remove an MCP or skill、Credential protection(登录凭据保护)、Copy MCP setup for a tool not listed(JSON 片段生成)、Exit。设计上采用 questionary 复选框选择器,Add 提供 Select all detected,Remove 提供 Select all found,且列表初始不预选任何项;清单中展示工具名、检测/配置状态与目标作用域或路径。值得注意的检测原则:仅存在 Skill 目录不视为宿主编译已安装,未检测到的工具不会进入 Add 的 Select all 候选。
安全基元层:ConfigParseError、私有备份与原子写入
计划 Task 1 沉淀出四个可复用的安全基元,全部实现在 setup_safety.py,并被直接命令与向导共用:
read_json_config:缺失返回 {},畸形必须失败关闭
旧的 JSON 读取器对“读取失败”静默回退 {} 会掩盖损坏文件,新版将其改为显式报错:
class ConfigParseError(ValueError):
def __init__(self, path: Path, cause: Exception):
super().__init__(f"Cannot parse {path}: {cause}")
self.path = path
self.cause = cause
def read_json_config(path: Path) -> dict:
if not path.exists():
return {}
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ConfigParseError(path, exc) from exc
if not isinstance(value, dict):
raise ConfigParseError(path, ValueError("expected a JSON object"))
return value
语义:文件不存在 → {}(新建场景);文件存在但 JSON 畸形或顶层非对象 → 抛 ConfigParseError,且原文件字节保持不动。配套测试 test_malformed_json_is_not_replaced 用 {"servers": {,} 验证“解析失败后原内容原样保留”。setup.py 中的私有 _read_json_config / _write_json_config 保留为薄包装以兼容既有调用方与测试(setup.py),_write_json_config 返回备份路径供汇总展示。
backup_existing:时间戳 + 随机后缀的私有备份
def backup_existing(path: Path, *, label: str) -> Path | None:
if path.is_symlink():
raise ValueError(f"Refusing symbolic link: {path}")
if not path.exists():
return None
root = get_storage_dir() / "backups" # 与应用同根目录,遵循 NOTEBOOKLM_MCP_CLI_PATH
if root.is_symlink():
raise ValueError(f"Refusing symbolic-link backup root: {root}")
root.mkdir(parents=True, mode=0o700, exist_ok=True)
root.chmod(0o700)
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
backup_path = root / f"{stamp}-{uuid.uuid4().hex[:8]}-{label}"
...
关键行为(对应实现计划中的约束):
- 备份命名形如
20260927T120000Z-3fa8c2d1-mcp-config,时间戳保证顺序、随机后缀避免并发冲突、label说明用途; - 文件与目录都支持:目录用
shutil.copytree(..., symlinks=True)整树复制; - 权限收紧:目录
0o700、普通文件0o600,但对保留了源所有者可执行位的脚本(stat.S_IXUSR)保持可执行; - 拒绝符号链接:目标路径或备份根目录是 symlink 一律
ValueError拒绝,绝不替换链接本身; - 复制/权限失败时回滚已创建的备份目录/文件后重新抛出;
- 备份根目录实际使用
get_storage_dir() / "backups"(默认即~/.notebooklm-mcp-cli/backups/,且遵循NOTEBOOKLM_MCP_CLI_PATH环境变量),而非硬编码家目录,见 setup_safety.py。
atomic_write_text:同目录临时文件 + os.replace
def atomic_write_text(path: Path, content: str) -> None:
if path.is_symlink():
raise ValueError(f"Refusing symbolic link: {path}")
path.parent.mkdir(parents=True, exist_ok=True)
temp_fd, temp_path_str = tempfile.mkstemp(dir=path.parent, prefix=".tmp-nlm-")
temp_path = Path(temp_path_str)
try:
with os.fdopen(temp_fd, "w", encoding="utf-8") as f:
f.write(content)
if path.exists():
temp_path.chmod(path.stat().st_mode & 0o777)
os.replace(temp_path, path)
finally:
if temp_path.exists():
temp_path.unlink(missing_ok=True)
设计要点:临时文件创建在目标同目录(保证 os.replace 原子且同文件系统);写入后保留原文件权限位;finally 中清理残留临时文件——中断也不会留下半成品。_write_json_config 的完整写路径是:先备份、json.dumps(indent=2) 序列化、再 json.loads 校验序列化结果、最后原子写入。
capture_backups:ContextVar 收集备份清单
向导要给每个目标展示“本次操作产生了哪些备份”,但适配器返回的是布尔值(bool),接口不便改动。解决方式是用 ContextVar 记录日志:
_backup_log: ContextVar[list[Path] | None] = ContextVar("nlm_setup_backups", default=None)
@contextmanager
def capture_backups() -> Iterator[list[Path]]:
recorded: list[Path] = []
token = _backup_log.set(recorded)
try:
yield recorded
finally:
_backup_log.reset(token)
向导在每个适配器调用外套 with capture_backups() as recorded:,随后把 recorded 拷贝进 SetupResult.backup_paths,见 setup_wizard.py 的 run_add。
共享目标:Codex CLI 与 ChatGPT 桌面端只配置一次
计划 Task 2 处理本仓库最具平台特性的适配器。Codex CLI 与 ChatGPT 桌面端在同一主机共享 ~/.codex/config.toml,因此向导把它们合并为一个目标 Codex CLI / ChatGPT desktop app;chatgpt-desktop 成为 nlm setup add 的别名(setup.py),并且 wizard 选择列表里二者只出现一次。
检测与路径解析
_detect_chatgpt_desktop():macOS 检查/Applications/ChatGPT.app与~/Applications/ChatGPT.app;Windows 检查%LOCALAPPDATA%\Programs\ChatGPT\ChatGPT.exe;Linux 检查/opt/chatgpt、.desktop文件或chatgpt可执行命令(setup.py);_codex_config_path():优先尊重非空CODEX_HOME环境变量,否则回退~/.codex(setup.py)。向导会在输出中展示解析后的真实路径;若CODEX_HOME与桌面端默认位置不同,则明确说明“CLI 写入并不能证明桌面端可用”;- 检测任一客户端(
codex命令或桌面端安装痕迹)即视为该目标已安装;仅存在 Skill 目录不算。
写入策略:绝对路径 + 300 秒超时 + tomlkit 往返编辑
为 Codex 新增条目时有两条硬要求:解析 notebooklm-mcp 可执行文件的绝对路径(桌面端不继承 shell PATH),并设置 tool_timeout_sec = 300——因为 NotebookLM 查询/建源/研究/Studio 操作可达 60~120 秒以上,远超 Codex 默认 60 秒。流程是:
- CLI 可用时调用
codex mcp add gemini-notebook-mcp -- <绝对路径>(调用前先备份既有config.toml); - 无论 CLI 是否成功写入,都用
tomlkit对config.toml做注释与无关键保留的往返编辑,强制command为绝对路径、args = []、tool_timeout_sec = 300; - 若第二步(设置超时)失败,报告部分失败并给出备份路径,绝不谎称成功;
- CLI 缺失但桌面端已检测到时,直接用同一个 tomlkit 编辑器改 TOML(桌面端独占移除同样走此路径)。
核心编辑逻辑(setup.py):
doc = tomlkit.parse(path.read_text(encoding="utf-8") if path.exists() else "")
if "mcp_servers" not in doc:
doc["mcp_servers"] = tomlkit.table()
servers = doc["mcp_servers"]
entry = servers.get(MCP_SERVER_NAME) or tomlkit.table()
entry["command"] = command # 解析后的绝对路径
entry["args"] = []
entry["tool_timeout_sec"] = 300
servers[MCP_SERVER_NAME] = entry
atomic_write_text(path, tomlkit.dumps(doc))
tomlkit 解析失败会在任何变更前转换为 ConfigParseError(fail-closed);既有遗留名(notebooklm-mcp、notebooklm)在 _edit_codex_entry 中被识别并迁移到 gemini-notebook-mcp,同时尽力保留旧条目自身设置(如 enabled、env)。
修复而非静默替换
已存在的条目被展示为“已配置”,绝不无提示覆盖。_codex_repair_reason()(setup.py)检测两类缺陷并给出人话原因:
command仍是裸命令名而非绝对路径(当可执行文件可解析时);tool_timeout_sec缺失或小于 300。
向导只有在先展示原因并征得用户同意后才以 repair=True 调用 _setup_codex(repair=True);直接命令 nlm setup add codex 保持原有“不替换”行为不变。配套测试 test_codex_cli_add_uses_absolute_server_path 同时断言了传给 codex mcp add 的最后一个参数是绝对路径、且写入后 config.toml 中 tool_timeout_sec == 300(见 tests/cli/test_setup_codex_desktop.py)。
GitHub Copilot:用户级作用域与 JSONC 防御
计划 Task 3 引入显式作用域路由。VS Code 既支持工作区 .vscode/mcp.json,也支持用户级 profile 的 mcp.json;向导为了兑现“默认全局(All projects)”的承诺,对 GitHub Copilot 使用用户级 profile,而直接命令保持项目级默认、新增 --scope user 选项与向导对齐(setup_add/setup_remove 对非 Copilot 客户端使用 --scope 会直接报错拒绝,见 setup.py)。
路径解析(_github_copilot_config_path(scope))
| 平台 | project 作用域 | user 作用域(VS Code 默认用户 profile) |
|---|---|---|
| macOS | .vscode/mcp.json |
~/Library/Application Support/Code/User/mcp.json |
| Windows | .vscode/mcp.json |
%APPDATA%\Code\User\mcp.json(APPDATA 缺失返回 None) |
| Linux | .vscode/mcp.json |
$XDG_CONFIG_HOME/Code/User/mcp.json(缺省 ~/.config/Code/User/mcp.json) |
实现见 setup.py。当用户级路径无法解析(如 APPDATA 缺失)或自定义 profile 无法识别时,安全跳过该目标并给出路径与 VS Code UI 操作指引,绝不把 .vscode/mcp.json 写进一个碰巧的工作目录——测试 test_unknown_copilot_profile_does_not_write_project_file 专门验证这一点。
优先 code --add-mcp,JSONC 绝不改写
用户级写入优先走 VS Code 官方支持的 code --add-mcp 命令(setup.py):
absolute_server_path = _find_mcp_server_path()
if absolute_server_path is None:
console.print("[red]notebooklm-mcp is not installed in PATH[/red]")
return False
payload = json.dumps({"name": MCP_SERVER_NAME, "command": absolute_server_path, "args": []})
result = subprocess.run([code_cmd, "--add-mcp", payload], capture_output=True, text=True, timeout=15)
if result.returncode != 0:
console.print(f"[red]VS Code rejected MCP setup:[/red] {result.stderr.strip()}")
return False
调用前先定位并备份用户级 profile 配置文件。VS Code 的 mcp.json 可能含 JSONC 注释与尾逗号:code --add-mcp 成功但文件是 JSONC 时,依赖客户端命令结果并说明本地校验受限,绝不重写该文件;移除时若 JSONC 无法安全往返编辑,则打印精确文件路径并跳过(fail-closed)。严格 JSON 的直接写入仍走 Task 1 的安全写入器;移除时只编辑被识别的 servers 键,保证无关 servers 条目存活(有专项测试覆盖)。
安全的 Skill 安装、升级与移除
计划 Task 4 的核心是 SkillActionResult 数据类与内部操作 skill_action,二者都实现在 skill.py 与 skill.py。
@dataclass(frozen=True)
class SkillActionResult:
status: str # "installed" | "updated" | "current" | "newer" | "removed" | "skipped" | "failed"
path: Path | None
backup_path: Path | None
message: str
版本比较决定“装、升、跳过、拒绝降级”
current_version = _get_installed_version(tool, level) if installed else None
try:
comparison = Version(current_version) if current_version else None
except InvalidVersion:
comparison = None
if installed and comparison == Version(__version__):
return SkillActionResult("current", path, None, "Already current")
if installed and comparison is not None and comparison > Version(__version__):
return SkillActionResult("newer", path, None, "Newer skill preserved")
- 版本比较使用
packaging.version.Version(packaging>=24,<27为直接依赖,见 pyproject.toml); - 已装版本 ≥ 包版本 → 跳过,绝不降级;测试
test_newer_skill_is_never_downgraded用 SKILL.md frontmatter 中version: "99.0.0"验证“newer”状态且原文件不被改写; - 已装版本更旧或无法解析版本号(视为 unversioned)→ 必须征得显式确认才替换,并展示已知版本;
- 替换或删除前对整个 Skill 目录备份(
backup_existing(path, label=f"skill-{tool}-{level}"));备份失败则该目录保持原样,shutil.rmtree绝不执行——测试test_remove_backup_failure_keeps_skill验证备份抛PermissionError时SKILL.md仍存在、状态为failed。
共享目标去重与 Claude Desktop 的 MCP-only 定位
TOOL_CONFIGS(skill.py)把 codex、chatgpt-desktop、gemini-cli、agents 都映射到同一个 ~/.agents/skills/nlm-skill(用户级)与 .agents/skills/nlm-skill(项目级)——向导按解析后的安装路径去重,共享目录只出现一行并标注“one shared file covers these”。Claude Desktop 使用账号级 Skill、本地 ~/.claude/skills 属于 Claude Code 目标,因此 Claude Desktop 在向导中仅作为 MCP 目标,除非同时检测到 Claude Code(skill_action 对 claude-desktop 直接返回 failed 并说明原因)。skill-only 的 OpenClaw、Hermes 在检测到时也进入候选;alef-agent 与 other 从向导选择中排除。
安装完成后对每个选中目标单独产出 SkillActionResult,backup_path 写入结果汇总;直接命令 nlm skill install/uninstall/update 的提示与输出保持稳定(经内部 helper 路由或等价包装)。
向导编排:SetupTarget / SetupResult 与 Add / Remove / JSON 流程
计划 Task 5 与 Task 6 把上述适配器编排成两个数据类和四个流程函数,全部位于 setup_wizard.py。
数据模型
@dataclass(frozen=True)
class SetupTarget:
id: str
label: str
installed: bool
configured: bool
destination: Path | None
skill_id: str | None
repair_reason: str | None = None
@dataclass(frozen=True)
class SetupResult:
id: str
status: str
destination: Path | None
backup_paths: tuple[Path, ...]
message: str
scan_mcp_targets()(setup_wizard.py)为每个检测到的客户端构造 SetupTarget:Codex/ChatGPT 合并为单目标并携带 repair_reason;GitHub Copilot 以 scope="user" 解析;其余客户端经 CLIENT_REGISTRY 循环,通过各自的 _xxx_config_path() 得出 destination,并依据 skill.TOOL_CONFIGS 得出 skill_id(Gemini CLI 共享 agents 文件、Claude Desktop 为 None)。
Add 流程
run_add(selected)(setup_wizard.py)逐目标执行:
with capture_backups() as recorded:
try:
configured = add_one_mcp(client, repair=targets[client].repair_reason is not None)
status = "configured" if configured else "failed"
message = "Configured" if configured else "Setup failed"
except (OSError, ConfigParseError, ValueError) as exc:
status, message = "failed", str(exc)
- 已配置且无修复原因 →
already;修复场景区分repaired(含“旧名迁移”专门提示); - Codex 特殊部分失败判定:条目已存在但超时设置失败 →
partial并提示“inspect the backup”; - 单个失败不阻断后续;
KeyboardInterrupt汇总后以退出码 130 结束。
MCP 连接完成后进入可选的 Skill 步骤:先问 All my projects (user level)(默认)还是 Just this folder (project level),然后展示候选工具并预勾选刚完成 MCP 接入的工具与有可用升级的工具,同时提供 Select all 与 Claude Desktop / claude.ai 上传文件选项(生成可上传的 zip,见 _make_upload_zip)。
Remove 流程
scan_removable()(setup_wizard.py)只扫描已知用户/应用级配置位置与用户/项目级 Skill 位置,不遍历任意项目目录。Claude Desktop 的 regular 与 Relay AI/3P profile 各自成为独立可移除行;Codex/ChatGPT 共享 TOML 与共享 agents Skill 目录只出现一次;GitHub Copilot 的 user 与 project 作用域分别列出。
删除环节两道默认 No 的独立确认(setup_wizard.py):
if mcp_targets:
allowed = questionary.confirm("Remove the selected MCP entries?", default=False).ask()
results.extend(remove_mcp_targets(mcp_targets) if allowed else skipped(mcp_targets))
if skill_targets:
allowed = questionary.confirm("Delete the listed skill folders? Personal edits in the active folders will be removed.", default=False).ask()
results.extend(remove_skill_targets(skill_targets) if allowed else skipped(skill_targets))
remove_mcp_targets 把 id 形如 claude-desktop:3p、github-copilot:user 解析为 client/profile/scope 传给 _remove_single,避免任何嵌套的意外提问;Claude Desktop 进程守卫(运行中拒绝写入,防止应用覆写配置)在移除时同样生效。JSONC 无法安全编辑时打印精确文件与手工移除指引;共享父目录绝不递归删除。
JSON 生成与剪贴板
“Copy MCP setup for a tool not listed”复用 _setup_json()(setup.py)与 build_json_snippet():支持 uvx 或已安装可执行文件两种模式、完整路径或命令名、单条目或带 mcpServers 包装的完整配置。剪贴板复制(setup_wizard.py)按平台探测:macOS pbcopy、Windows clip、Linux 依次尝试 wl-copy、xclip -selection clipboard、xsel --clipboard --input;不可用时保持 JSON 可见并如实说明“未复制”,绝不谎报。
测试策略与完成验收
实现计划为每个 Task 都规定了先写失败测试、再实现的顺序,测试文件与当前仓库一一对应:
| Task | 测试文件 | 覆盖重点 |
|---|---|---|
| 1 安全基元 | tests/cli/test_setup_safety.py | 缺失/畸形 JSON、备份失败、symlink 拒绝、目录备份、原子替换、权限;配合 tests/cli/test_setup_github_copilot.py、tests/cli/test_setup_opencode.py |
| 2 Codex/ChatGPT | tests/cli/test_setup_codex_desktop.py | 三平台桌面检测、绝对路径与超时、保留注释的 TOML、畸形 TOML fail-closed、自定义 CODEX_HOME、遗留名移除;配合 tests/cli/test_mcp_branding.py |
| 3 Copilot 作用域 | tests/cli/test_setup_github_copilot.py | 三平台 user profile 路径、未知 profile 安全跳过、JSONC 跳过、remove all 找到用户级条目、无关 servers 存活 |
| 4 Skill 动作 | tests/cli/test_setup_skill_actions.py、tests/cli/test_skill_install.py | user/project 目标、agents 去重、newer/current 跳过、旧版/无版本确认、删除前整目录备份、备份失败保留、Claude Desktop-only 省略 |
| 5/6 向导流程 | tests/cli/test_setup_wizard.py | 裸 setup 与 --help 区分、非 TTY 退出 1、多选与 Select all、取消 130 且无临时文件残留、单目标失败不阻塞、Codex/ChatGPT 单目标、全局 Copilot 路由、剪贴板三平台回退 |
完整验收步骤(计划 “Completion check” 一节):运行 git diff --check、git status --short、uv run pytest 全量测试与 CLI 帮助冒烟检查(uv run nlm setup --help),报告提交列表、精确测试结果、手工限制(尤其自定义 VS Code profile 与 JSONC 移除)与备份/恢复位置。恢复方式很简单:把 ~/.notebooklm-mcp-cli/backups/ 下带时间戳的备份文件或目录复制回原路径。开发过程在隔离 worktree 中进行,不影响主线 checkout 中尚未发布的修复。
依赖、约束与边界速查
| 项 | 说明 | 依据 |
|---|---|---|
| Python | >=3.11 |
pyproject.toml |
| 交互组件 | Typer + Rich + questionary>=2.1,<3 |
pyproject.toml |
| TOML 往返编辑 | tomlkit>=0.13,<1(计划目标 >=0.15,<1) |
pyproject.toml |
| 版本比较 | packaging>=24,<27 |
pyproject.toml |
| 备份位置 | get_storage_dir()/backups,默认 ~/.notebooklm-mcp-cli/backups/,遵循 NOTEBOOKLM_MCP_CLI_PATH |
setup_safety.py |
| 服务器标识 | gemini-notebook-mcp;遗留名 notebooklm-mcp、notebooklm 仅识别/迁移/移除,不写入新配置 |
setup.py |
| 退出码 | 非交互 1;用户取消 130;正常 0 | setup_wizard.py |
| 排除项 | Alef Agent(MCP 与 Skill 均不出现于向导) | 计划 Global Constraints 与 setup_wizard.py |
整套设计最终回答了一个工程问题:如何在完全不联网、不改动无关配置的前提下,把“识别工具 → 定位配置 → 备份 → 原子写入 → 结果汇报”做成对用户零配置知识要求的一站式体验。对于希望接入本仓库 MCP 服务器的开发者,从 docs/CLI_GUIDE.md 的向导使用说明出发,结合本文的源码路径,即可在理解每一条安全边界的前提下安全地完成多工具接入、Skill 安装与清理恢复。