Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@

## [Unreleased]

### Fixed — 第四轮批次②(互操作顺序 / upgrade 范围 / 解码泄漏 / 双语矩阵,报告 F5–F7、F10)

- **npm uninstall 判定前置**(F5):旧顺序"先删 `.drogon-plugin` 后读 v3 戳"会把混合安装(如 claude+agents)的 bundle 删掉,留下 bundle 缺失而戳宣称 claude/zcode 已装的说谎状态。现在先判账本:无戳(npm 自装自卸)或纯 bundle → bundle+戳全清;混合/损坏戳 → **bundle、legacy、戳一概不动**,提示交由 PyPI CLI 收尾(含损坏戳正向对照测试)。
- **py upgrade 认 v2 bundle 布局**(F6):npm 装的项目无 v3 戳,upgrade 曾按"无戳退化全量"凭空落下 8 家宿主产物。现在无 v3 戳但检出 bundle 布局(v2/legacy)→ 范围收敛为 claude,zcode;完全无产物才全量。
- **非 UTF-8 用户文件三条泄漏路径**(F7):上轮只修了卸载剥段——①`verify` 遇 GBK 化 `AGENTS.md` 直接 traceback;②`install` 遇解不动的既有指令文件整批失败;③legacy 卸载读 `CLAUDE.md` 崩溃。三处统一按"读不动=不碰":verify 显示宿主 `—`、install 视为用户文件 skipped+告警、legacy 保留文件。
- **README 双语矩阵漂移**(F10):zh-CN 宿主矩阵 Qoder/CodeBuddy 行与 CLI 表行补投 `.qoder/skills`/`.codebuddy/skills`(N4/N5 此前只改了英文侧,"README 已标注"至此才双语属实);`check-consistency` 的"双语 README 结构对齐"项扩为**宿主矩阵路径单元格对拍**(一行漂移即红,含两文件同错的假绿防线,注入漂移实测见红)。
- 测试基线变更:批次④的 L1 混合宿主对照测试随 F5 语义更新(断言 bundle **保留**而非删除)。

### Docs — 第四轮评审(诊断报告,未整改)

- `docs/ROUND4-INCREMENTAL-CORRECTNESS-REVIEW.md`:对 v0.4.0 后 10 提交增量做四轴对抗评审。上轮声称项全部复核成立;新增 F1–F15(高危 4:钩子正则 ReDoS 实测 42s/62s、回滚三态失败分支可毁在用 bundle/备份/用户技能内容;中 6;低 5)。V7 执行位结论被平台轴动摇,转 V9 走 ubuntu CI 实证。建议整改序见报告 §3,待确认后按批 TDD 开工。
Expand Down
6 changes: 3 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
| **Cursor** | 项目内 `drogon-claude-plugin install --host cursor` → `.cursor/skills/` + `.cursor/rules/` | 技能 + 规则 |
| **VS Code (Copilot)** | `drogon-claude-plugin install --host copilot` → `.agents/skills/` + `AGENTS.md` | 技能 + 规则 |
| **Gemini CLI** | `gemini extensions install https://github.com/voidvec/drogon-claude-plugin` | 技能 + GEMINI.md 上下文 |
| **Qoder** | `drogon-claude-plugin install`(落 `AGENTS.md`) | 规则 |
| **CodeBuddy** | `drogon-claude-plugin install`(落 `CODEBUDDY.md`) | 规则 |
| **Qoder** | 项目内 `drogon-claude-plugin install --host qoder` → `.qoder/skills/` + `AGENTS.md` | 技能 + 规则(技能目录待真机核验) |
| **CodeBuddy** | `drogon-claude-plugin install --host codebuddy` → `.codebuddy/skills/` + `CODEBUDDY.md` | 技能 + 规则(技能目录待真机核验) |
| **Trae** | `drogon-claude-plugin install`(落 `.trae/skills/` + `AGENTS.md`) | 技能 + 规则 |

维护分层:**一级维护**(持续跟进规范变化)— Claude Code、ZCode、Codex、Cursor、VS Code + `.agents` 兜底;**尽力维护** — Gemini CLI、Qoder、CodeBuddy、Trae。
Expand Down Expand Up @@ -71,7 +71,7 @@ drogon-claude-plugin verify # CLI 安装器(任意宿主皆可校验

| 命令 | 作用 |
|------|------|
| `drogon-claude-plugin install [--host LIST] [--force-agents]` | 按宿主落产物:Claude/ZCode 走 bundle,Cursor 落 `.cursor/skills`+规则,Codex/Gemini/Qoder/CodeBuddy 落指令文件,Trae 落 `.trae/skills` + `AGENTS.md`;`all` 遵循互斥规则(不重复落 `.agents/skills`) |
| `drogon-claude-plugin install [--host LIST] [--force-agents]` | 按宿主落产物:Claude/ZCode 走 bundle,Cursor 落 `.cursor/skills`+规则,Codex/Gemini 落指令文件,Qoder 落 `.qoder/skills` + `AGENTS.md`,CodeBuddy 落 `.codebuddy/skills` + `CODEBUDDY.md`,Trae 落 `.trae/skills` + `AGENTS.md`;`all` 遵循互斥规则(不重复落 `.agents/skills`) |
| `drogon-claude-plugin scan [--format json] [--strict] [路径...]` | 无钩子宿主 / CI 的违规扫描(`--strict` 发现违规退出码 1);路径限定在项目内 |
| `drogon-claude-plugin hosts` | 列出支持的宿主与接入方式 |
| `drogon-claude-plugin verify [--target DIR]` | 校验已安装结构并逐宿主报告状态 |
Expand Down
51 changes: 30 additions & 21 deletions npm/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,34 @@ function cmdUninstall(args) {
const project = resolveProjectDir(args)
const removed = []

// 回归(L1/第四轮 F5):先读 PyPI v3 戳判定,再决定删什么。
// 旧顺序"先删 bundle 后判账本"会把混合安装(如 claude+agents)的 bundle 删掉,
// 留下 bundle 缺失但戳宣称 claude/zcode 已装的互相说谎状态。
// 判定:无 v3 戳(npm 自装自卸)或 hosts ⊆ {claude,zcode}(纯 bundle)→ 全清;
// 混合/损坏戳 → bundle、legacy、戳一概不动,提示用 PyPI CLI 收尾。
const root = installRootOf(project)
const v3 = path.join(project, PY_V3_STAMP)
const hasV3 = fs.existsSync(v3)
let hosts = null
if (hasV3) {
try {
const data = JSON.parse(fs.readFileSync(v3, 'utf-8'))
if (Array.isArray(data.hosts)) hosts = data.hosts
} catch {
/* 损坏戳按混合处理,不盲目删除 */
}
}
const pureBundle =
!hasV3 ||
(hosts !== null && hosts.length > 0 && hosts.every((h) => h === 'claude' || h === 'zcode'))

if (!pureBundle) {
console.warn(`⚠️ ${PY_V3_STAMP} 记录的宿主为 ${JSON.stringify(hosts)}(混合或不可读)。`)
console.warn(' bundle 与安装戳均未改动;请用 pipx 安装的 drogon-claude-plugin uninstall 完成卸载。')
console.log(`ℹ️ 未在 ${project} 移除任何资产(交由 PyPI CLI)`)
return 0
}

if (fs.existsSync(root)) {
fs.rmSync(root, { recursive: true, force: true })
removed.push(INSTALL_DIR)
Expand All @@ -461,27 +488,9 @@ function cmdUninstall(args) {
const legacy = removeLegacyLayout(project)
removed.push(...legacy.removed)

// 回归(L1):PyPI CLI 在项目根写 v3 戳;npm uninstall 若不识别则永久残留。
// hosts ⊆ {claude,zcode}(纯 bundle 安装)→ 一并清除;
// 混合宿主/戳损坏 → 保留记录并提示用 PyPI CLI 收尾(它才管理根指令文件)。
const v3 = path.join(project, PY_V3_STAMP)
if (fs.existsSync(v3)) {
let hosts = null
try {
const data = JSON.parse(fs.readFileSync(v3, 'utf-8'))
if (Array.isArray(data.hosts)) hosts = data.hosts
} catch {
/* 损坏戳按混合处理,不盲目删除 */
}
const pureBundle = hosts !== null && hosts.length > 0 &&
hosts.every((h) => h === 'claude' || h === 'zcode')
if (pureBundle) {
fs.rmSync(v3, { force: true })
removed.push(PY_V3_STAMP)
} else {
console.warn(`⚠️ ${PY_V3_STAMP} 记录的宿主为 ${JSON.stringify(hosts)}(混合或不可读),已保留;`)
console.warn(' 请用 pipx 安装的 drogon-claude-plugin uninstall 完成其余宿主的卸载。')
}
if (hasV3) {
fs.rmSync(v3, { force: true })
removed.push(PY_V3_STAMP)
}

if (removed.length) {
Expand Down
36 changes: 36 additions & 0 deletions scripts/check-consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,31 @@ def _skill_table_rows(rel: str) -> "list[str]":
return [line for line in read(rel).splitlines() if SKILL_TABLE_ROW.match(line)]


HOST_MATRIX_ROW = re.compile(r"^\|\s*\*\*(.+?)\*\*\s*\|(.*)$")
BACKTICK_TOKEN = re.compile(r"`([^`]+)`")


def _host_matrix_rows(rel: str) -> "dict[str, str]":
"""取文件里**第一个**粗体首列表格(即宿主矩阵)→ {宿主名: 行内容}。
只取第一张表,避免把后文"产物结构"等粗体表误拉进对拍面。"""
out = {}
started = False
for line in read(rel).splitlines():
m = HOST_MATRIX_ROW.match(line)
if m:
out[m.group(1).strip()] = m.group(2)
started = True
elif started:
break
return out


def _cell_paths(row_text: str) -> "set[str]":
"""行内的路径/文件名形态反引号单元格(落点),用于双语对拍;
命令串与散文措辞(不参与对拍)天然被 '/'·'.md' 判据滤掉。"""
return {t for t in BACKTICK_TOKEN.findall(row_text) if "/" in t or t.endswith(".md")}


def _check_readme_skill_coverage() -> "list[str]":
"""双语主 README 必须覆盖每个技能,且不得引用不存在的技能名。"""
problems = []
Expand Down Expand Up @@ -145,6 +170,17 @@ def _check_readme_bilingual_parity() -> "list[str]":
n_rows = len(_skill_table_rows(rel))
if n_rows < n_skills:
problems.append(f"{rel}: 技能表格仅 {n_rows} 行 < 技能数 {n_skills}")

# 回归(第四轮 F10):双语宿主矩阵的关键单元格必须对拍 —— 此前 N4/N5 只改了
# 英文行落点(.qoder/skills 等),zh-CN 仍写"落 AGENTS.md",一份文档教错。
en, zh = "README.md", "README.zh-CN.md"
ea, eb = _host_matrix_rows(en), _host_matrix_rows(zh)
if set(ea) != set(eb) or not ea:
problems.append(f"{en} / {zh}: 宿主矩阵行集合不一致 {sorted(set(ea) ^ set(eb)) or '均为空(假绿防线)'}")
for host in sorted(set(ea) & set(eb)):
pa, pb = _cell_paths(ea[host]), _cell_paths(eb[host])
if pa != pb:
problems.append(f"{zh}: 宿主 {host} 落点与 {en} 不一致 zh={sorted(pa)} en={sorted(pb)}")
return problems


Expand Down
24 changes: 17 additions & 7 deletions src/drogon_plugin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,8 @@ def _remove_legacy_layout(project: Path):
first_line = ""
try:
first_line = md.read_text(encoding="utf-8").split("\n", 1)[0].strip()
except OSError:
pass
except (OSError, UnicodeDecodeError):
pass # F7:读不动/解不动按非插件文件处理,绝不崩溃也绝不删
if first_line == _CLAUDE_MD_MARKER:
md.unlink()
removed.append("CLAUDE.md")
Expand Down Expand Up @@ -323,7 +323,11 @@ def _write_instruction_file(project: Path, name: str, content: str, force: bool)
if not p.exists():
p.write_text(section, encoding="utf-8")
return "full"
text = p.read_text(encoding="utf-8")
try:
text = p.read_text(encoding="utf-8")
except UnicodeDecodeError:
# F7:解不动(如用户转了 GBK)= 用户自有文件,保守 skipped,不拖死整批安装
return "skipped"
if _MARKER_SECTION_RE.search(text):
# 已有完整标记段(可能多段):全部替换为最新内容(升级语义)
p.write_text(_MARKER_SECTION_RE.sub(section.rstrip("\n") + "\n", text), encoding="utf-8")
Expand Down Expand Up @@ -655,8 +659,8 @@ def _host_artifact_ok(project: Path, conf: dict) -> bool:
else:
try:
ok = ok and (_MARKER_BEGIN in p.read_text(encoding="utf-8"))
except OSError:
ok = False
except (OSError, UnicodeDecodeError):
ok = False # F7:不可读的宿主产物判"未就绪(—)",verify 不得崩溃
return ok


Expand Down Expand Up @@ -758,15 +762,21 @@ def cmd_upgrade(args) -> int:
ALL_HOSTS 重装。两个后果:①给用户只装过部分宿主的项目凭空落下其余八家产物;
②把标记段强推进用户自有的 AGENTS.md/GEMINI.md/CODEBUDDY.md。
新语义:范围 = 安装戳里已安装的宿主(--host 可显式覆盖);marker 追加是
显式 opt-in(--force-agents),永不默认发生;无安装戳退化为全量安装。
显式 opt-in(--force-agents),永不默认发生;无 v3 戳但检出 bundle 布局
(v2/legacy,含 npm CLI 装的项目)→ 收敛为 claude,zcode(F6);
完全无产物时才退化为全量安装。
"""
if not getattr(args, "host", None):
try:
project = _project_dir(args)
stamped = [h for h in _load_stamp(project).get("hosts", []) if h in HOSTS]
if not stamped:
layout, _root = _detect_bundle_layout(project)
if layout: # F6:bundle-only 项目(npm 装无 v3 戳)不得放大到 8 家宿主
stamped = ["claude", "zcode"]
except ValueError:
stamped = []
# _parse_hosts 对空串/None 走 ALL_HOSTS —— 无戳即无范围,保持旧行为
# _parse_hosts 对空串/None 走 ALL_HOSTS —— 无任何产物即无范围,保持旧行为
args.host = ",".join(sorted(set(stamped))) if stamped else None
return cmd_install(args)

Expand Down
87 changes: 86 additions & 1 deletion tests/test_hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,9 @@ def test_npm_uninstall_keeps_mixed_host_v3_stamp(tmp_path):
capture_output=True, text=True, encoding="utf-8",
)
assert r.returncode == 0, r.stdout + r.stderr
assert not (p / ".drogon-plugin").exists(), "npm 应清掉自己管的 bundle"
# F5 语义变更:混合账本下 bundle 也可能由 PyPI CLI 写入,npm 无权单方面删——
# 此前"先删 bundle 后判账本"留下 bundle 缺失但戳宣称 claude 已装的互相说谎态。
assert (p / ".drogon-plugin").is_dir(), "F5 复发:混合宿主项目的 bundle 被 npm 单方面删掉"
assert v3.is_file(), "混合宿主戳被误删:npm 只应清除纯 bundle(claude/zcode)安装"
assert json.loads(v3.read_text(encoding="utf-8"))["hosts"] == hosts_before, (
"保留的戳内容被改写(账本必须原样)"
Expand All @@ -359,6 +361,26 @@ def test_npm_uninstall_keeps_mixed_host_v3_stamp(tmp_path):
assert "drogon-claude-plugin uninstall" in (r.stdout + r.stderr), "应提示用 PyPI CLI 收尾"


@pytest.mark.skipif(_node() is None, reason="node not available")
def test_npm_uninstall_corrupt_stamp_keeps_everything(tmp_path):
"""F5 正向对照:损坏的 v3 戳按混合处理——bundle 与戳都原样保留,只告警。"""
import shutil as _sh
import subprocess

p = tmp_path / "broken-stamp"
p.mkdir()
assert _run_cli("install", "--target", str(p), "--host", "claude") == 0
(p / ".drogon-plugin-install.json").write_text("{ 损坏", encoding="utf-8")

r = subprocess.run(
[_node(), str(REPO_ROOT / "npm" / "bin" / "cli.js"), "uninstall", "--target", str(p)],
capture_output=True, text=True, encoding="utf-8",
)
assert r.returncode == 0, r.stdout + r.stderr
assert (p / ".drogon-plugin").is_dir(), "戳不可读时不得删 bundle(无法证明是纯 bundle 安装)"
assert (p / ".drogon-plugin-install.json").is_file(), "损坏戳也应保留待人工/PyPI CLI 处置"


# ---------------------------------------------------------------------------
# 回归(第三轮批次④-C):M4′ 残缺包放行 / L3 整删用户追加 / L5 半途孤儿
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1022,6 +1044,28 @@ def test_upgrade_only_repairs_installed_hosts(tmp_path):
assert sorted(stamp["hosts"]) == ["cursor", "gemini"]


@pytest.mark.skipif(_node() is None, reason="node not available")
def test_py_upgrade_of_npm_installed_project_stays_in_bundle(tmp_path):
"""F6:npm 装的项目只有 bundle(v2 布局)无 v3 戳,py upgrade 曾退化为
ALL_HOSTS 全量安装,凭空落下 8 家宿主产物。修复:检出 v2 布局 → 收敛 claude,zcode。"""
import subprocess

p = tmp_path / "npmproj"
p.mkdir()
r = subprocess.run(
[_node(), str(REPO_ROOT / "npm" / "bin" / "cli.js"), "install", "--target", str(p)],
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
assert r.returncode == 0, r.stdout + r.stderr
assert (p / ".drogon-plugin").is_dir(), "前提:npm 已落 bundle"

assert _run_cli("upgrade", "--target", str(p)) == 0
assert (p / ".drogon-plugin" / ".claude-plugin" / "plugin.json").is_file()
for stray in (".cursor", ".agents", ".qoder", ".codebuddy", ".trae",
"AGENTS.md", "GEMINI.md", "CODEBUDDY.md"):
assert not (p / stray).exists(), f"F6 复发:upgrade 越界落下 {stray}"


def test_upgrade_never_forces_marker_into_user_instruction_files(tmp_path):
"""用户自有 AGENTS.md:upgrade 默认(不带 --force-agents)必须一字不动。"""
p = _make_project(tmp_path, with_user_agents=True)
Expand Down Expand Up @@ -1117,5 +1161,46 @@ def test_codex_hint_names_real_subcommand_add_not_install():
assert "codex plugin install" not in text, f"{name} 仍在教不存在的 `codex plugin install`"


# ---------------------------------------------------------------------------
# 回归(第四轮 F7):非 UTF-8 用户文件的三条泄漏路径
# (上轮只修了卸载剥段;verify/install/legacy 三条路径仍漏 UnicodeDecodeError)
# ---------------------------------------------------------------------------

_BAD_UTF8 = b"\xff\xfe" + "项目规则".encode("gbk", errors="replace")


def test_verify_survives_undecodable_instruction_file(tmp_path, capsys):
"""F7-①:_host_artifact_ok 只捕 OSError → GBK 化的 AGENTS.md 让 verify 直接 traceback。"""
p = _make_project(tmp_path)
assert _run_cli("install", "--target", str(p), "--host", "agents") == 0
(p / "AGENTS.md").write_bytes(_BAD_UTF8)
capsys.readouterr()
rc = _run_cli("verify", "--target", str(p)) # 修复前:UnicodeDecodeError 冒到顶层
assert rc in (0, 1)
assert "agents—" in capsys.readouterr().out, "不可读宿主须显示为 —(不假装通过、不崩溃)"


def test_install_skips_undecodable_instruction_file(tmp_path, capsys):
"""F7-②:_write_instruction_file 裸 read_text → 一个坏字节让整批多宿主安装失败。
解不动 = 用户自有文件:skipped + 原字节 + 告警。"""
p = _make_project(tmp_path)
(p / "AGENTS.md").write_bytes(_BAD_UTF8)
rc = _run_cli("install", "--target", str(p), "--host", "agents")
assert rc == 0, "F7 复发:不可解码文件拖死整个安装"
assert (p / "AGENTS.md").read_bytes() == _BAD_UTF8, "原字节必须一字不动"
assert "⚠" in capsys.readouterr().out, "skipped 需告警"


def test_remove_legacy_layout_survives_undecodable_claude_md(tmp_path):
"""F7-③:_remove_legacy_layout 的 CLAUDE.md 首行签名判定只捕 OSError →
旧项目卸载遇 GBK CLAUDE.md 崩溃。读不动 → 按非插件文件保留。"""
p = _make_project(tmp_path)
(p / "CLAUDE.md").write_bytes(_BAD_UTF8)
(p / ".drogon-claude-plugin-installed.json").write_text("{}", encoding="utf-8")
removed, skipped = cli_mod._remove_legacy_layout(p)
assert (p / "CLAUDE.md").read_bytes() == _BAD_UTF8, "读不动的 CLAUDE.md 不得被删/损坏"
assert any("CLAUDE.md" in s for s in skipped), f"应记为保留: skipped={skipped}"


if __name__ == "__main__":
raise SystemExit(pytest.main([__file__]))
Loading