Просмотр исходного кода

feat(v7): M5 安装器——init/update 哈希三态 + vendoring + 壳落位 + hook 接线

- detect:PATH 纯函数探测(注入 env,win32 PATHEXT);--hosts 覆盖;探测不到只装公约数层+指引(D-2)
- vendor:bin/src/roles/package.json+js-yaml 传递依赖收集进 .webnovel/,清单键统一正斜杠(跨平台可移植)
- manifest:sha256 三态(unchanged 覆写/user-modified 跳过列清单 --force 覆盖/missing 重建)
- AGENTS.md 标记块管理:块内归安装器块外归用户,无标记块时前置;books.jsonl 只保底永不覆盖
- claude-code SessionStart hook 保留式合并进 .claude/settings.json(幂等,不进清单)
- registry 扩 detect_bin/install_dir,validator 同步校验;护栏:书仓库内拒装/vendored 自更新指引 npx
lingfengQAQ 22 часов назад
Родитель
Сommit
b1a4a05e50

+ 10 - 2
v7/adapters/registry.json

@@ -6,6 +6,8 @@
       "verified": "结构就绪,真模型 smoke 推迟 beta",
       "agentCapable": true,
       "hasHooks": true,
+      "detect_bin": "claude",
+      "install_dir": ".claude",
       "smoke": "node scripts/smoke.mjs --host claude-code",
       "smoke_status": "deferred-beta"
     },
@@ -14,6 +16,8 @@
       "verified": "结构就绪,真模型 smoke 推迟 beta",
       "agentCapable": true,
       "hasHooks": false,
+      "detect_bin": "codex",
+      "install_dir": ".codex",
       "smoke": "node scripts/smoke.mjs --host codex",
       "smoke_status": "deferred-beta"
     },
@@ -21,13 +25,17 @@
       "tier": 2,
       "verified": "社区反馈",
       "agentCapable": true,
-      "hasHooks": false
+      "hasHooks": false,
+      "detect_bin": "gemini",
+      "install_dir": ".gemini"
     },
     "cursor": {
       "tier": 2,
       "verified": "社区反馈",
       "agentCapable": true,
-      "hasHooks": false
+      "hasHooks": false,
+      "detect_bin": "cursor-agent",
+      "install_dir": ".cursor"
     },
     "_default": {
       "tier": 3,

+ 14 - 0
v7/src/commands/init.js

@@ -0,0 +1,14 @@
+import { installWorkdir } from '../installer/index.js'
+
+/**
+ * init [--hosts=a,b] [--force]:一条命令装出工作目录(multi-agent spec §8.1)。
+ * 已有安装时等价 update(哈希三态,不静默覆盖手改)。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export const scope = 'anywhere'
+
+export async function run(args, options, ctx) {
+  const hostsOverride = options.hosts && options.hosts !== true ? options.hosts : null
+  const r = await installWorkdir(ctx, { hostsOverride, force: !!options.force })
+  return r.ok ? { ok: true, output: r.report } : { ok: false, error: r.error }
+}

+ 14 - 0
v7/src/commands/update.js

@@ -0,0 +1,14 @@
+import { installWorkdir } from '../installer/index.js'
+
+/**
+ * update [--hosts=a,b] [--force]:升级既有工作目录(multi-agent spec §8.2)。
+ * 哈希未变的文件直接更新;你改过的提示并跳过(--force 覆盖);AGENTS.md 只动标记块内。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export const scope = 'workdir'
+
+export async function run(args, options, ctx) {
+  const hostsOverride = options.hosts && options.hosts !== true ? options.hosts : null
+  const r = await installWorkdir(ctx, { hostsOverride, force: !!options.force })
+  return r.ok ? { ok: true, output: r.report } : { ok: false, error: r.error }
+}

+ 7 - 0
v7/src/host-shells/validator.js

@@ -26,6 +26,13 @@ export async function validatePackage(baseDir) {
   for (const [host, h] of Object.entries(registry.hosts)) {
     if (host === '_default') continue
     if (h.tier == null) errors.push(`${host} 缺 tier`)
+    if (h.tier === 1 || h.tier === 2) {
+      // 安装器依赖:探测可执行名 + 壳落位目录(M5)
+      if (!h.detect_bin) errors.push(`${host} 缺 detect_bin(安装器探测用)`)
+      if (!h.install_dir || !/^\.[\w.-]+$/.test(h.install_dir)) {
+        errors.push(`${host} 缺合法 install_dir(形如 .claude)`)
+      }
+    }
     if (h.tier === 1) {
       try {
         await fs.access(path.join(baseDir, 'adapters', host, 'support.md'))

+ 50 - 0
v7/src/installer/detect.js

@@ -0,0 +1,50 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+/**
+ * PATH 探测已装的 agent CLI(multi-agent spec §8.1)。纯函数注入 env/platform,可测。
+ * 按 registry 声明顺序返回命中的 host 名;探测名来自 registry 的 detect_bin。
+ */
+export async function detectHosts(registry, { env = process.env, platform = process.platform } = {}) {
+  const pathVar = env.PATH || env.Path || ''
+  const dirs = pathVar.split(path.delimiter).filter(Boolean)
+  const exts =
+    platform === 'win32'
+      ? ['', ...(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)]
+      : ['']
+  const hits = []
+  for (const [host, cfg] of Object.entries(registry.hosts)) {
+    if (host === '_default' || !cfg.detect_bin) continue
+    if (await findOnPath(cfg.detect_bin, dirs, exts)) hits.push(host)
+  }
+  return hits
+}
+
+async function findOnPath(bin, dirs, exts) {
+  for (const dir of dirs) {
+    for (const ext of exts) {
+      try {
+        const stat = await fs.stat(path.join(dir, bin + ext))
+        if (stat.isFile()) return true
+      } catch {
+        // 该目录没有 → 继续
+      }
+    }
+  }
+  return false
+}
+
+/** 解析 --hosts=a,b 覆盖:校验都在 registry 内,给人话错误 */
+export function parseHostsOverride(value, registry) {
+  const names = String(value)
+    .split(',')
+    .map((s) => s.trim())
+    .filter(Boolean)
+  if (!names.length) return { ok: false, error: '--hosts 需要逗号分隔的宿主名(如 --hosts=claude-code,codex)' }
+  const known = Object.keys(registry.hosts).filter((h) => h !== '_default')
+  const unknown = names.filter((n) => !known.includes(n))
+  if (unknown.length) {
+    return { ok: false, error: `不认识的宿主:${unknown.join('、')}。可用:${known.join('、')}` }
+  }
+  return { ok: true, hosts: names }
+}

+ 233 - 3
v7/src/installer/index.js

@@ -1,3 +1,233 @@
-// 安装器:npx webnovel-writer init / update,工作目录布局、平台壳生成、模板哈希追踪。
-// 占位——真实实现见 M5。
-export {}
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { detectHosts, parseHostsOverride } from './detect.js'
+import { collectRuntimeFiles } from './vendor.js'
+import { readManifest, writeManifest, classifyFile, sha256 } from './manifest.js'
+import { buildShellFiles, mergeClaudeSettings, SESSION_HOOK_COMMAND } from './shells.js'
+
+/**
+ * 安装器编排(multi-agent spec §8):init/update 同一核心——环境检测 → 组文件集
+ * (vendor 运行时 + 平台壳 + AGENTS.md 标记块)→ 哈希三态写入 → books.jsonl 保底 →
+ * hook 接线 → 清单 → 人话报告。幂等可重跑;不联网、不改全局、工作目录不建 git 仓库(§8.3)。
+ */
+
+const START = '<!-- WEBNOVEL:START -->'
+const END = '<!-- WEBNOVEL:END -->'
+const AGENTS = 'AGENTS.md'
+
+export async function installWorkdir(ctx, { hostsOverride = null, force = false, env = process.env } = {}) {
+  const { workdir, packageRoot } = ctx
+
+  // 护栏:vendored 副本自我更新是空转
+  if (path.basename(packageRoot) === '.webnovel') {
+    return fail('这是装进工作目录的运行副本,自我更新是空转。请在工作目录运行:npx webnovel-writer update')
+  }
+  // 护栏:别把工作目录装进书仓库里
+  if (await exists(path.join(workdir, 'book.yaml'))) {
+    return fail('这里是书仓库(有 book.yaml)。工作目录应是它的上一层——请到上一层目录运行 init。')
+  }
+
+  let registry
+  try {
+    registry = JSON.parse(await fs.readFile(path.join(packageRoot, 'adapters', 'registry.json'), 'utf8'))
+  } catch (err) {
+    return fail(`安装包不完整(读不到宿主注册表):${err.message}`)
+  }
+  let pkgVersion = ''
+  try {
+    pkgVersion = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8')).version || ''
+  } catch {
+    // 版本缺失不阻断
+  }
+
+  // 宿主集:显式覆盖 > 探测 ∪ 既装(update 时已装壳的宿主继续跟新)
+  const detected = await detectHosts(registry, { env })
+  const manifest = await readManifest(workdir)
+  const alreadyInstalled = manifestHosts(manifest, registry)
+  let hosts
+  if (hostsOverride) {
+    const p = parseHostsOverride(hostsOverride, registry)
+    if (!p.ok) return fail(p.error)
+    hosts = p.hosts
+  } else {
+    hosts = [...new Set([...detected, ...alreadyInstalled])]
+  }
+
+  // 文件集:vendor 运行时 + 平台壳(books.jsonl / settings.json 不在内,单独处理)
+  let files
+  try {
+    files = {
+      ...(await collectRuntimeFiles(packageRoot)),
+      ...(await buildShellFiles(packageRoot, registry, hosts)),
+    }
+  } catch (err) {
+    return fail(`组装安装文件失败:${err.message}`)
+  }
+
+  // 哈希三态写入
+  const written = []
+  const skipped = []
+  const newFiles = { }
+  for (const [rel, content] of Object.entries(files)) {
+    const full = path.join(workdir, rel)
+    const disk = await readIfExists(full)
+    const cls = classifyFile(rel, disk == null ? null : sha256(disk), manifest)
+    const newHash = sha256(content)
+    if (cls === 'user-modified' && !force) {
+      skipped.push(rel)
+      newFiles[rel] = manifest.files[rel] // 保留旧记录,下次仍能识别用户改动
+      continue
+    }
+    if (disk !== content) {
+      await fs.mkdir(path.dirname(full), { recursive: true })
+      await fs.writeFile(full, content, 'utf8')
+      written.push(rel)
+    }
+    newFiles[rel] = newHash
+  }
+
+  // AGENTS.md:标记块管理(块内归安装器,块外归用户)
+  const agents = await installAgentsMd(workdir, packageRoot, manifest, force)
+  if (agents.written) written.push(AGENTS)
+  if (agents.skipped) skipped.push(AGENTS)
+  if (agents.hash) newFiles[AGENTS] = agents.hash
+
+  // books.jsonl:用户数据,只保底存在,永不覆盖、不进清单
+  const booksPath = path.join(workdir, '.webnovel', 'books.jsonl')
+  if (!(await exists(booksPath))) {
+    await fs.mkdir(path.dirname(booksPath), { recursive: true })
+    await fs.writeFile(booksPath, '', 'utf8')
+  }
+
+  // claude-code hook 接线:settings.json 保留式合并,不进清单
+  const notes = []
+  if (hosts.includes('claude-code')) {
+    const settingsPath = path.join(workdir, '.claude', 'settings.json')
+    const merged = mergeClaudeSettings(await readIfExists(settingsPath), SESSION_HOOK_COMMAND)
+    if (merged.error) notes.push(merged.error)
+    else if (merged.changed) {
+      await fs.mkdir(path.dirname(settingsPath), { recursive: true })
+      await fs.writeFile(settingsPath, merged.content, 'utf8')
+      written.push('.claude/settings.json')
+    }
+  }
+  if (agents.note) notes.push(agents.note)
+
+  await writeManifest(workdir, { version: pkgVersion, files: newFiles })
+
+  const report = buildReport({
+    workdir,
+    version: pkgVersion,
+    hosts,
+    detected,
+    registry,
+    written,
+    skipped,
+    notes,
+    updating: !!manifest,
+    force,
+  })
+  return { ok: true, report, hosts, detected, written, skipped, notes, error: '' }
+}
+
+/* ---------- AGENTS.md 标记块 ---------- */
+
+async function installAgentsMd(workdir, packageRoot, manifest, force) {
+  let templateBlock
+  try {
+    const t = await fs.readFile(path.join(packageRoot, 'templates', 'AGENTS.md'), 'utf8')
+    templateBlock = extractBlock(t) ?? t.trim()
+  } catch (err) {
+    return { note: `AGENTS.md 模板缺失,跳过:${err.message}` }
+  }
+  const full = path.join(workdir, AGENTS)
+  const hash = sha256(templateBlock)
+  const existing = await readIfExists(full)
+
+  if (existing == null) {
+    await fs.writeFile(full, templateBlock + '\n', 'utf8')
+    return { written: true, hash }
+  }
+  const currentBlock = extractBlock(existing)
+  if (currentBlock == null) {
+    // 用户自己的 AGENTS.md(无标记块):块前置,原内容全保留
+    await fs.writeFile(full, templateBlock + '\n\n' + existing, 'utf8')
+    return { written: true, hash, note: '你已有 AGENTS.md:webnovel 块已加到开头,原内容未动。' }
+  }
+  const cls = classifyFile(AGENTS, sha256(currentBlock), manifest)
+  if (cls === 'user-modified' && !force) {
+    return { skipped: true, hash: manifest.files[AGENTS] }
+  }
+  if (currentBlock !== templateBlock) {
+    await fs.writeFile(full, existing.replace(currentBlock, templateBlock), 'utf8')
+    return { written: true, hash }
+  }
+  return { hash }
+}
+
+function extractBlock(text) {
+  const i = text.indexOf(START)
+  const j = text.indexOf(END)
+  if (i === -1 || j === -1 || j < i) return null
+  return text.slice(i, j + END.length)
+}
+
+/* ---------- 报告与杂项 ---------- */
+
+function manifestHosts(manifest, registry) {
+  if (!manifest) return []
+  const hosts = []
+  for (const [host, cfg] of Object.entries(registry.hosts)) {
+    if (host === '_default' || !cfg.install_dir) continue
+    // 清单键统一正斜杠
+    if (Object.keys(manifest.files).some((k) => k.startsWith(cfg.install_dir + '/'))) {
+      hosts.push(host)
+    }
+  }
+  return hosts
+}
+
+function buildReport({ workdir, version, hosts, detected, registry, written, skipped, notes, updating, force }) {
+  const lines = []
+  lines.push(`${updating ? '升级' : '安装'}完成:${path.basename(workdir) || workdir}(webnovel-writer ${version})`)
+  lines.push(`Node ${process.version} ✓(门槛 22.13.0)`)
+
+  if (hosts.length) {
+    lines.push('平台壳:')
+    for (const h of hosts) {
+      const cfg = registry.hosts[h] || {}
+      const tier = cfg.tier === 1 ? '一级(维护者亲测口径)' : cfg.tier === 2 ? '二级(社区反馈口径)' : '三级'
+      const cap = []
+      cap.push(cfg.agentCapable ? '两审=独立 subagent' : '两审=兼容模式(顺序自审,如实声明)')
+      cap.push(cfg.hasHooks ? '启动自动注入书单(SessionStart)' : '启动由入口读书单(与 hook 等价)')
+      lines.push(`  - ${h} → ${cfg.install_dir}/  ${tier};${cap.join(';')}${detected.includes(h) ? '' : '(未在 PATH 检测到,按既装/指定生成)'}`)
+    }
+  } else {
+    lines.push('未检测到任何 agent CLI:只装了公约数层(AGENTS.md + .webnovel/)。')
+    lines.push('装好某个 agent CLI 后重跑 init,或显式指定:npx webnovel-writer init --hosts=claude-code,codex')
+  }
+
+  lines.push(`写入 ${written.length} 个文件${skipped.length ? `;跳过 ${skipped.length} 个你改过的文件:${skipped.join('、')}${force ? '' : '(要覆盖用 --force)'}` : ''}`)
+  for (const n of notes) lines.push(`注意:${n}`)
+  lines.push('下一步:在这个目录打开你的 agent CLI,对它说「开始写书」。')
+  return lines.join('\n')
+}
+
+async function exists(p) {
+  try {
+    await fs.access(p)
+    return true
+  } catch {
+    return false
+  }
+}
+async function readIfExists(p) {
+  try {
+    return await fs.readFile(p, 'utf8')
+  } catch {
+    return null
+  }
+}
+function fail(error) {
+  return { ok: false, report: '', hosts: [], detected: [], written: [], skipped: [], notes: [], error }
+}

+ 46 - 0
v7/src/installer/manifest.js

@@ -0,0 +1,46 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { createHash } from 'node:crypto'
+
+/**
+ * 模板哈希清单(multi-agent spec §8.2):.webnovel/manifest.json 记录安装器写出的每个文件的
+ * sha256。update 三态:unchanged(哈希==清单)→覆写新版;user-modified(!=)→跳过(--force 覆盖);
+ * missing(文件没了)→重建。books.jsonl(用户数据)与 .claude/settings.json(合并式)不进清单。
+ */
+
+const MANIFEST_REL = path.join('.webnovel', 'manifest.json')
+
+export function sha256(content) {
+  return createHash('sha256').update(content, 'utf8').digest('hex')
+}
+
+export async function readManifest(workdir) {
+  try {
+    const raw = await fs.readFile(path.join(workdir, MANIFEST_REL), 'utf8')
+    const m = JSON.parse(raw)
+    if (m && typeof m === 'object' && m.files && typeof m.files === 'object') return m
+    return null
+  } catch {
+    return null
+  }
+}
+
+export async function writeManifest(workdir, { version, files }) {
+  const p = path.join(workdir, MANIFEST_REL)
+  await fs.mkdir(path.dirname(p), { recursive: true })
+  // 键排序:清单可 diff、生成确定
+  const sorted = {}
+  for (const k of Object.keys(files).sort()) sorted[k] = files[k]
+  await fs.writeFile(p, JSON.stringify({ version, files: sorted }, null, 2) + '\n', 'utf8')
+}
+
+/**
+ * 三态判定。diskHash 传 null 表示文件不存在。
+ * @returns {'new'|'unchanged'|'user-modified'|'missing'}
+ */
+export function classifyFile(relPath, diskHash, manifest) {
+  const recorded = manifest?.files?.[relPath]
+  if (!recorded) return 'new' // 清单没记过(首装/新增文件)→ 直接写
+  if (diskHash == null) return 'missing' // 记过但文件没了 → 重建
+  return diskHash === recorded ? 'unchanged' : 'user-modified'
+}

+ 49 - 0
v7/src/installer/shells.js

@@ -0,0 +1,49 @@
+import { generateHostShells } from '../host-shells/generate.js'
+
+/**
+ * 平台壳落位(multi-agent spec §8.1):M4 生成器输出的相对布局(skills/、agents/)
+ * 平移进各宿主 install_dir(.claude/ .codex/ …)。只产文件集,写入统一走哈希三态。
+ */
+export async function buildShellFiles(packageRoot, registry, hosts) {
+  const all = await generateHostShells(packageRoot)
+  const files = {}
+  for (const host of hosts) {
+    const dir = registry.hosts[host]?.install_dir
+    if (!dir) continue
+    for (const [rel, content] of Object.entries(all[host] || {})) {
+      // 文件集键统一正斜杠(清单跨平台可移植;生成器 rel 本就是正斜杠)
+      files[`${dir}/${rel}`] = content
+    }
+  }
+  return files
+}
+
+/**
+ * claude-code SessionStart hook 接线:对 .claude/settings.json 做保留式合并——
+ * 只按 command 字符串幂等追加本项,用户已有 hooks/权限配置原样保留。
+ * settings.json 是用户主权文件,不进哈希清单,update 重复调用无副作用。
+ * @returns {{content: string, changed: boolean, error?: string}}
+ */
+export function mergeClaudeSettings(existingText, command) {
+  let settings = {}
+  if (existingText != null && existingText.trim()) {
+    try {
+      settings = JSON.parse(existingText)
+    } catch (err) {
+      return { content: existingText, changed: false, error: `settings.json 不是合法 JSON(${err.message}),跳过 hook 接线` }
+    }
+    if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
+      return { content: existingText, changed: false, error: 'settings.json 结构异常,跳过 hook 接线' }
+    }
+  }
+  if (JSON.stringify(settings).includes(command)) {
+    return { content: JSON.stringify(settings, null, 2) + '\n', changed: false }
+  }
+  if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {}
+  if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = []
+  settings.hooks.SessionStart.push({ hooks: [{ type: 'command', command }] })
+  return { content: JSON.stringify(settings, null, 2) + '\n', changed: true }
+}
+
+/** 会话上下文注入命令(hook 与 SKILL 缺省路径同源) */
+export const SESSION_HOOK_COMMAND = 'node .webnovel/bin/webnovel-writer.js session-context'

+ 62 - 0
v7/src/installer/vendor.js

@@ -0,0 +1,62 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { createRequire } from 'node:module'
+
+/**
+ * vendoring:把运行时复制进 .webnovel/(spec §2.0——工作目录自包含,离线可跑,版本可诊断)。
+ * 只产 {相对 workdir 路径: 内容} 的文件集,不直接写盘——统一走安装器的哈希三态写入。
+ * 复制源 = 当前运行的包根;js-yaml 及其传递依赖从本包的解析位置递归收集。
+ */
+
+const RUNTIME_ENTRIES = ['bin', 'src', 'roles', 'package.json']
+
+/** 文件集/清单键统一正斜杠:清单跨平台可移植(双平台 CI、网盘搬家不失配) */
+const posix = (p) => p.split(path.sep).join('/')
+
+export async function collectRuntimeFiles(packageRoot) {
+  const files = {}
+  for (const entry of RUNTIME_ENTRIES) {
+    await collectInto(files, path.join(packageRoot, entry), `.webnovel/${entry}`)
+  }
+  await collectDependency(files, 'js-yaml', new Set())
+  return files
+}
+
+/** 递归收集一个 npm 依赖及其 production 传递依赖到 .webnovel/node_modules/<name>/ */
+async function collectDependency(files, name, seen) {
+  if (seen.has(name)) return
+  seen.add(name)
+  const require = createRequire(import.meta.url)
+  let pkgJsonPath
+  try {
+    pkgJsonPath = require.resolve(`${name}/package.json`)
+  } catch {
+    throw new Error(`找不到运行时依赖 ${name}(安装包不完整)`)
+  }
+  const pkgDir = path.dirname(pkgJsonPath)
+  await collectInto(files, pkgDir, `.webnovel/node_modules/${name}`, {
+    skip: (rel) => rel === 'node_modules' || rel.startsWith(`node_modules${path.sep}`),
+  })
+  const pkg = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8'))
+  for (const dep of Object.keys(pkg.dependencies || {})) {
+    await collectDependency(files, dep, seen)
+  }
+}
+
+async function collectInto(files, srcPath, destRel, { skip } = {}) {
+  const stat = await fs.stat(srcPath)
+  if (stat.isFile()) {
+    files[destRel] = await fs.readFile(srcPath, 'utf8')
+    return
+  }
+  const walk = async (dir, relBase) => {
+    for (const e of await fs.readdir(dir, { withFileTypes: true })) {
+      const rel = relBase ? path.join(relBase, e.name) : e.name
+      if (skip && skip(rel)) continue
+      const full = path.join(dir, e.name)
+      if (e.isDirectory()) await walk(full, rel)
+      else if (e.isFile()) files[`${destRel}/${posix(rel)}`] = await fs.readFile(full, 'utf8')
+    }
+  }
+  await walk(srcPath, '')
+}

+ 1 - 1
v7/test/host-shells/validator.test.js

@@ -15,7 +15,7 @@ async function makePkg() {
     await fs.mkdir(path.dirname(full), { recursive: true })
     await fs.writeFile(full, c, 'utf8')
   }
-  await w('adapters/registry.json', JSON.stringify({ schema_version: 'webnovel-host-registry/v2', hosts: { 'claude-code': { tier: 1, agentCapable: true, hasHooks: true } } }))
+  await w('adapters/registry.json', JSON.stringify({ schema_version: 'webnovel-host-registry/v2', hosts: { 'claude-code': { tier: 1, agentCapable: true, hasHooks: true, detect_bin: 'claude', install_dir: '.claude' } } }))
   await w('adapters/claude-code/support.md', '# claude-code 核验\n')
   await w('roles/事实审查.md', '---\nname: 事实审查\ndescription: d\n---\nbody {{categories.factCheck}}')
   await w('skills/webnovel-writer/SKILL.md', '---\nname: webnovel-writer\ndescription: d\n---\nbody')

+ 183 - 0
v7/test/installer/install.test.js

@@ -0,0 +1,183 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { installWorkdir } from '../../src/installer/index.js'
+import { readManifest } from '../../src/installer/manifest.js'
+
+const exec = promisify(execFile)
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const PKG_ROOT = path.join(__dirname, '../..')
+const NO_ENV = { env: { PATH: '' } } // 探测归零,宿主全靠显式指定 → 测试确定性
+
+async function tmpWorkdir() {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-init-'))
+  return { root, ctx: { workdir: root, packageRoot: PKG_ROOT }, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
+}
+const read = (root, rel) => fs.readFile(path.join(root, rel), 'utf8')
+const exists = async (root, rel) => {
+  try {
+    await fs.access(path.join(root, rel))
+    return true
+  } catch {
+    return false
+  }
+}
+
+test('init:一条命令装出完整布局(AC2),vendored bin 自包含可跑', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir()
+  try {
+    const r = await installWorkdir(ctx, { hostsOverride: 'claude-code,codex', ...NO_ENV })
+    assert.equal(r.ok, true, r.error)
+
+    // .webnovel/ 运行时自包含
+    for (const rel of [
+      '.webnovel/bin/webnovel-writer.js',
+      '.webnovel/src/session/index.js',
+      '.webnovel/roles/事实审查.md',
+      '.webnovel/roles/编辑审.md',
+      '.webnovel/node_modules/js-yaml/package.json',
+      '.webnovel/package.json',
+      '.webnovel/manifest.json',
+      '.webnovel/books.jsonl',
+    ]) {
+      assert.ok(await exists(root, rel), `缺 ${rel}`)
+    }
+    // 平台壳落位
+    assert.ok(await exists(root, '.claude/skills/webnovel-writer/SKILL.md'))
+    assert.ok(await exists(root, '.claude/agents/事实审查.md'))
+    assert.ok(await exists(root, '.codex/agents/事实审查.toml'))
+    // hook 接线
+    const settings = JSON.parse(await read(root, '.claude/settings.json'))
+    assert.ok(JSON.stringify(settings).includes('session-context'))
+    // AGENTS.md 标记块
+    const agents = await read(root, 'AGENTS.md')
+    assert.ok(agents.includes('<!-- WEBNOVEL:START -->') && agents.includes('<!-- WEBNOVEL:END -->'))
+    // 报告人话
+    assert.ok(r.report.includes('claude-code') && r.report.includes('下一步'))
+
+    // vendored bin 真的能跑(js-yaml 链路含在内):list-books 走 session→storage→yaml
+    const out = await exec(process.execPath, [path.join(root, '.webnovel/bin/webnovel-writer.js'), 'list-books'], {
+      cwd: root,
+      encoding: 'utf8',
+    })
+    assert.ok(out.stdout.includes('还没有书'), out.stdout)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('init:未检测到宿主 → 只装公约数层 + --hosts 指引(D-2)', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir()
+  try {
+    const r = await installWorkdir(ctx, NO_ENV)
+    assert.equal(r.ok, true, r.error)
+    assert.ok(await exists(root, '.webnovel/bin/webnovel-writer.js'))
+    assert.ok(await exists(root, 'AGENTS.md'))
+    assert.ok(!(await exists(root, '.claude')), '未检测到不应装 claude 壳')
+    assert.ok(r.report.includes('--hosts'), '报告应指引 --hosts 补装')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('update 三态:未改→更新;手改→跳过并列出;--force→覆盖;删了→重建(AC3)', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir()
+  try {
+    await installWorkdir(ctx, { hostsOverride: 'claude-code', ...NO_ENV })
+    const skillRel = '.claude/skills/webnovel-writer/SKILL.md'
+    const rolesRel = '.claude/agents/事实审查.md'
+
+    // 手改一个生成文件
+    await fs.writeFile(path.join(root, skillRel), '作者自己改过的 SKILL', 'utf8')
+    // 删一个生成文件
+    await fs.rm(path.join(root, rolesRel))
+
+    const r = await installWorkdir(ctx, { hostsOverride: 'claude-code', ...NO_ENV })
+    assert.equal(r.ok, true, r.error)
+    assert.ok(r.skipped.includes(skillRel), `手改文件应跳过:${r.skipped}`)
+    assert.equal(await read(root, skillRel), '作者自己改过的 SKILL', '手改内容不得被覆盖')
+    assert.ok(await exists(root, rolesRel), '删掉的生成文件应重建')
+    assert.ok(r.report.includes('跳过') && r.report.includes('--force'))
+
+    // --force 覆盖手改
+    const rf = await installWorkdir(ctx, { hostsOverride: 'claude-code', force: true, ...NO_ENV })
+    assert.equal(rf.ok, true)
+    assert.notEqual(await read(root, skillRel), '作者自己改过的 SKILL')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('update:AGENTS.md 只动标记块,块外用户内容保留;books.jsonl 永不覆盖', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir()
+  try {
+    await installWorkdir(ctx, { hostsOverride: 'codex', ...NO_ENV })
+    // 用户在块外加了自己的说明;books.jsonl 有登记
+    const agents = await read(root, 'AGENTS.md')
+    await fs.writeFile(path.join(root, 'AGENTS.md'), agents + '\n## 我自己的备注\n别动这里。\n', 'utf8')
+    await fs.writeFile(path.join(root, '.webnovel/books.jsonl'), '{"书名":"星海","目录":"星海","当前":true}\n', 'utf8')
+
+    const r = await installWorkdir(ctx, { hostsOverride: 'codex', ...NO_ENV })
+    assert.equal(r.ok, true, r.error)
+    const after = await read(root, 'AGENTS.md')
+    assert.ok(after.includes('我自己的备注'), '块外用户内容必须保留')
+    assert.ok(after.includes('<!-- WEBNOVEL:START -->'))
+    assert.ok((await read(root, '.webnovel/books.jsonl')).includes('星海'), 'books.jsonl 不得覆盖')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('init:用户已有无标记块的 AGENTS.md → 块前置,原内容保留', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir()
+  try {
+    await fs.writeFile(path.join(root, 'AGENTS.md'), '# 我的项目说明\n手写内容。\n', 'utf8')
+    const r = await installWorkdir(ctx, NO_ENV)
+    assert.equal(r.ok, true)
+    const after = await read(root, 'AGENTS.md')
+    assert.ok(after.startsWith('<!-- WEBNOVEL:START -->'))
+    assert.ok(after.includes('我的项目说明'))
+    assert.ok(r.notes.some((n) => n.includes('AGENTS.md')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('护栏:书仓库里 init 拒绝;vendored 副本自更新指引 npx', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir()
+  try {
+    await fs.writeFile(path.join(root, 'book.yaml'), '书名: 测\n', 'utf8')
+    const r = await installWorkdir(ctx, NO_ENV)
+    assert.equal(r.ok, false)
+    assert.ok(r.error.includes('上一层'))
+
+    const vendored = await installWorkdir(
+      { workdir: root, packageRoot: path.join(root, '.webnovel') },
+      NO_ENV
+    )
+    assert.equal(vendored.ok, false)
+    assert.ok(vendored.error.includes('npx'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('update:既装宿主无需重新探测也继续跟新(manifest 记忆)', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir()
+  try {
+    await installWorkdir(ctx, { hostsOverride: 'codex', ...NO_ENV })
+    // 不给 hostsOverride、探测为空 → 仍应给 codex 跟新(从 manifest 推断既装)
+    const r = await installWorkdir(ctx, NO_ENV)
+    assert.equal(r.ok, true)
+    assert.deepEqual(r.hosts, ['codex'])
+    const m = await readManifest(root)
+    assert.ok(Object.keys(m.files).some((k) => k.startsWith('.codex')))
+  } finally {
+    await cleanup()
+  }
+})

+ 91 - 0
v7/test/installer/units.test.js

@@ -0,0 +1,91 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { detectHosts, parseHostsOverride } from '../../src/installer/detect.js'
+import { classifyFile, sha256, readManifest, writeManifest } from '../../src/installer/manifest.js'
+import { mergeClaudeSettings, SESSION_HOOK_COMMAND } from '../../src/installer/shells.js'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const REGISTRY = JSON.parse(
+  await fs.readFile(path.join(__dirname, '../../adapters/registry.json'), 'utf8')
+)
+
+async function tmpDir(prefix = 'wnw-inst-') {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
+  return { root, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
+}
+
+test('detectHosts:PATH 上有探测名才命中(win32 认 PATHEXT)', async () => {
+  const { root, cleanup } = await tmpDir('wnw-path-')
+  try {
+    // 造一个假 claude 可执行
+    const ext = process.platform === 'win32' ? '.cmd' : ''
+    await fs.writeFile(path.join(root, `claude${ext}`), '#!/bin/sh\n', 'utf8')
+    const env = { PATH: root, PATHEXT: '.COM;.EXE;.BAT;.CMD' }
+    const hits = await detectHosts(REGISTRY, { env })
+    assert.deepEqual(hits, ['claude-code'])
+    // 空 PATH → 全不命中
+    assert.deepEqual(await detectHosts(REGISTRY, { env: { PATH: '' } }), [])
+  } finally {
+    await cleanup()
+  }
+})
+
+test('parseHostsOverride:未知宿主人话报错并列可用项', () => {
+  const bad = parseHostsOverride('claude-code,不存在', REGISTRY)
+  assert.equal(bad.ok, false)
+  assert.ok(bad.error.includes('不存在') && bad.error.includes('codex'))
+  const good = parseHostsOverride('codex, cursor', REGISTRY)
+  assert.deepEqual(good.hosts, ['codex', 'cursor'])
+})
+
+test('manifest:三态判定 + 读写往返', async () => {
+  const { root, cleanup } = await tmpDir()
+  try {
+    const m = { version: '7.0.0-alpha', files: { 'a.md': sha256('旧内容') } }
+    await writeManifest(root, m)
+    const back = await readManifest(root)
+    assert.equal(back.files['a.md'], sha256('旧内容'))
+
+    assert.equal(classifyFile('新.md', null, back), 'new')
+    assert.equal(classifyFile('a.md', null, back), 'missing')
+    assert.equal(classifyFile('a.md', sha256('旧内容'), back), 'unchanged')
+    assert.equal(classifyFile('a.md', sha256('用户改过'), back), 'user-modified')
+    assert.equal(classifyFile('a.md', sha256('x'), null), 'new')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('mergeClaudeSettings:新建/保留用户配置/幂等/坏 JSON 不动', () => {
+  // 新建
+  const a = mergeClaudeSettings(null, SESSION_HOOK_COMMAND)
+  assert.equal(a.changed, true)
+  const parsedA = JSON.parse(a.content)
+  assert.equal(parsedA.hooks.SessionStart[0].hooks[0].command, SESSION_HOOK_COMMAND)
+
+  // 保留用户已有 hooks 与其他配置
+  const user = JSON.stringify({
+    permissions: { allow: ['Bash'] },
+    hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo 自己的' }] }] },
+  })
+  const b = mergeClaudeSettings(user, SESSION_HOOK_COMMAND)
+  assert.equal(b.changed, true)
+  const parsedB = JSON.parse(b.content)
+  assert.deepEqual(parsedB.permissions.allow, ['Bash'])
+  assert.equal(parsedB.hooks.SessionStart.length, 2)
+
+  // 幂等:再合并不重复
+  const c = mergeClaudeSettings(b.content, SESSION_HOOK_COMMAND)
+  assert.equal(c.changed, false)
+  assert.equal(JSON.parse(c.content).hooks.SessionStart.length, 2)
+
+  // 坏 JSON → 不动原文,报 error
+  const d = mergeClaudeSettings('{坏的', SESSION_HOOK_COMMAND)
+  assert.equal(d.changed, false)
+  assert.ok(d.error)
+  assert.equal(d.content, '{坏的')
+})