From 867989fe6e41c4d40f74e85763b5859c50d9d720 Mon Sep 17 00:00:00 2001 From: telagod Date: Thu, 18 Jun 2026 13:04:00 +0800 Subject: [PATCH] feat(abyss-integration): consume abyss skill-manifest for capability discovery (v4.8.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-abyss 4.8.0 now reads abyss's skill-manifest JSON when the installed abyss binary is >= 0.5.22. The manifest is the contract: - providers.cli.commands — what abyss exposes - providers.mcp.tools — MCP tools list - providers.daemon.verbs — daemon socket capabilities - schema_version: 1 — shape pin Falls back to hard-coded defaults when abyss is missing, < 0.5.22, or the manifest fails to parse. The pre-existing injectClaudeHooks, hook scripts, and adapter shapes are unchanged — this patch is ADDITIVE. MIN_ABYSS_VERSION bumped 0.3.0 → 0.5.20 (the most recent stable; v0.5.x has been thoroughly dogfooded against hono/helix/vite/FastAPI/Django/ SQLAlchemy). 0.5.22+ unlocks dynamic capability discovery. --- CHANGELOG.md | 20 ++++++ README.md | 1 + bin/install.js | 22 ++++++- bin/lib/abyss-integration.js | 72 ++++++++++++++++++++- package.json | 2 +- test/abyss-integration.test.js | 115 ++++++++++++++++++++++++++++++++- 6 files changed, 225 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 184acf1..ba00a94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [4.8.0] - 2026-06-18 + +> **Minor: dynamic capability discovery via `abyss skill-manifest`.** code-abyss 4.8 reads abyss's manifest at install time and surfaces the discovered CLI / MCP / daemon surface to the user, instead of carrying a hand-maintained mirror. + +### Added + +- **`tryReadAbyssManifest()` helper** in `bin/lib/abyss-integration.js`. Spawns `abyss skill-manifest --compact`, validates `schema_version === 1`, returns the parsed object or `null`. Never throws — callers fall back to hard-coded defaults. Version-gated at `SKILL_MANIFEST_AVAILABLE_FROM = 0.5.22` (the release that introduced the subcommand). +- **Install-time discovery summary.** After abyss is detected (or downloaded via `--with-abyss`), `ensureAbyssBinary` calls `discoverAbyssCapabilities()` and prints e.g. `abyss v0.5.23: 19 CLI commands, 8 MCP tools; daemon verbs: ping, stats, reindex, logs, mcp, subscribe`, so users see the contract their toolchain actually exposes. +- **`resolveAbyssMcpTools(manifest, fallback)`** as the future single source of truth for the MCP tool list. Falls back to the hard-coded 7-tool list when manifest is unavailable, so older abyss installs keep working unchanged. +- **`summarizeAbyssManifest(manifest)`** — `null`-safe one-line formatter used by the install summary and reusable from any consumer. + +### Changed + +- **`MIN_ABYSS_VERSION` bumped `0.3.0` → `0.5.20`.** The 0.5.x line has been dogfooded across hono/helix/vite/FastAPI/Django/SQLAlchemy corpora; older builds never saw the codegen-aware indexer or the bounded temporal mining. `0.5.22+` additionally unlocks dynamic capability discovery via `abyss skill-manifest`. + +### Compatibility + +- Additive only. `injectClaudeHooks` / `injectGeminiHooks` / hook scripts / adapter shapes are unchanged. +- Hosts with no abyss installed, or with abyss `< 0.5.22`, still install successfully — the discovery summary is simply silent. + ## [4.7.2] - 2026-06-13 > **Patch: Codex install no longer corrupts `config.toml`.** Codex 0.125+ rejected the hooks schema we generated, breaking `codex` startup outright. diff --git a/README.md b/README.md index f1d50c4..529e2cf 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Pick any persona. Pair it with any style. The behavior layer (iron laws, executi - **v4.5 — dynamic persona loading**: only `abyss` ships with npm — all other personas are fetched from GitHub on first use and cached locally, slimming the package - **v4.6 — code graph intelligence**: `abyss` CLI builds a code relationship graph (call graph + temporal analysis) in seconds — caller tracing, impact analysis, hotspot detection, change coupling. Pre-edit hooks auto-check callers across all 4 platforms - **v4.7 — measured resolution**: `abyss` v0.3.3 ships four-language reference resolution (Go / TypeScript / Python / Rust), benchmarked against SCIP ground truth across five corpora at ≥98.5% gated precision. Named-import binding tiers, receiver-type inference, and type-grade evidence — published numbers, not claims. `npm install -g @code-abyss/cli` +- **v4.8 — dynamic capability discovery**: code-abyss reads `abyss skill-manifest` when the installed `abyss` is ≥ 0.5.22 — exposed CLI commands, MCP tools, and daemon socket verbs are now discovered at install time instead of hard-coded. `MIN_ABYSS_VERSION` bumped 0.3.0 → 0.5.20 (the 0.5.x line dogfooded across hono / helix / vite / FastAPI / Django / SQLAlchemy). Hand-coded fallbacks are preserved, so older `abyss` installs keep working unchanged. ```bash npx code-abyss -t claude -y --with-abyss diff --git a/bin/install.js b/bin/install.js index c603c4d..c665a81 100755 --- a/bin/install.js +++ b/bin/install.js @@ -147,18 +147,25 @@ const { detectAbyss, injectClaudeMcp, injectGeminiMcp, + tryReadAbyssManifest, + summarizeAbyssManifest, } = require(path.join(__dirname, 'lib', 'abyss-integration.js')); const { installAbyssBinary } = require(path.join(__dirname, 'lib', 'abyss-binary.js')); // 本次运行的 abyss 探测结果(ensureAbyssBinary 后更新) let abyssState = null; +// 0.5.22+ 才有 skill-manifest;缺它不报错,只是不打印发现摘要 +let abyssManifest = null; // 二进制下载是外网行为:--with-abyss 显式才下载;交互模式询问;-y 只提示不下载。 // CODE_ABYSS_SKIP_BINARY=1 在 headless/CI/测试场景下静默跳过 detect 之外的一切 // (不询问、不下载),让交互安装在无网/无 stdin 的环境里也不阻塞。 async function ensureAbyssBinary() { abyssState = detectAbyss({ HOME }); - if (abyssState) return; + if (abyssState) { + discoverAbyssCapabilities(); + return; + } if (process.env.CODE_ABYSS_SKIP_BINARY) return; let doInstall = withAbyss; if (!doInstall && !autoYes) { @@ -173,11 +180,24 @@ async function ensureAbyssBinary() { if (r.installed) { ok(`abyss 二进制 → ${c.cyn(r.binPath)}`); abyssState = detectAbyss({ HOME }); + discoverAbyssCapabilities(); } else { warn(`abyss 下载未完成: ${r.reason}(hook 将静默停用,可稍后手动安装)`); } } +// 走 abyss skill-manifest 拿能力清单(0.5.22+)。失败静默——manifest 是增强不是依赖。 +function discoverAbyssCapabilities() { + if (!abyssState) return; + try { + abyssManifest = tryReadAbyssManifest({ binPath: abyssState.binPath, HOME }); + } catch { + abyssManifest = null; + } + const line = summarizeAbyssManifest(abyssManifest); + if (line) info(line); +} + async function installTargetFlow(targetName, installOptions = {}) { const persona = installOptions.persona || await resolveInstallPersona(); const style = installOptions.style || await resolveInstallStyle(targetName); diff --git a/bin/lib/abyss-integration.js b/bin/lib/abyss-integration.js index 9a7194f..eaa7ed7 100644 --- a/bin/lib/abyss-integration.js +++ b/bin/lib/abyss-integration.js @@ -20,8 +20,13 @@ const path = require('path'); const os = require('os'); const { spawnSync } = require('child_process'); -// indexing-code hook 薄壳依赖 `abyss hook pre-edit`(>= 0.3.0) -const MIN_ABYSS_VERSION = '0.3.0'; +// indexing-code hook 薄壳依赖 `abyss hook pre-edit`(>= 0.3.0 起稳定), +// 但 0.5.x 才完成跨语言 (hono/helix/vite/FastAPI/Django/SQLAlchemy) 的全量 +// 回归。0.5.22+ 增加了 `abyss skill-manifest`,让 code-abyss 动态发现 abyss +// 暴露的 CLI 命令、MCP 工具、daemon 套接字 verbs,告别硬编码列表。 +const MIN_ABYSS_VERSION = '0.5.20'; +// 0.5.22+ 才有 skill-manifest 子命令;缺它即 fallback 回硬编码默认。 +const SKILL_MANIFEST_AVAILABLE_FROM = '0.5.22'; const HOOK_MARKER = 'indexing-code/hooks/common'; function resolveAbyssHookDir(targetDir) { @@ -184,6 +189,65 @@ function checkLockToolRequirement(lock, detected) { return { ok: true, spec, required, actual: detected.version }; } +// ── 能力发现:abyss skill-manifest(0.5.22+) ── +// +// 设计契约: +// - 永不抛错。manifest 解析失败、abyss 版本过旧、二进制不存在——一律 null。 +// - 调用方必须有硬编码 fallback。manifest 是「增强」不是「依赖」。 +// - schema_version 严格 == 1。abyss 改 schema 时需要 code-abyss 同步适配, +// 而不是静默吞掉一个不认识的 shape。 +// +// 用途: +// - 安装后摘要:打印 "abyss vX.Y.Z: N CLI commands, M MCP tools",让用户 +// 看到能力发现链路走通。 +// - MCP 注册(未来扩展):用 manifest.providers.mcp.tools 替代硬编码列表。 +// - daemon 验证:providers.daemon.verbs 可用于探测 subscribe 等新 verb 支持。 +function tryReadAbyssManifest({ binPath = null, HOME = os.homedir(), timeoutMs = 2000 } = {}) { + let abyss = binPath; + if (!abyss) { + const detected = detectAbyss({ HOME }); + if (!detected) return null; + abyss = detected.binPath; + if (!detected.version || compareVersions(detected.version, SKILL_MANIFEST_AVAILABLE_FROM) < 0) { + return null; + } + } else { + // binPath 显式传入也需要版本闸:避免对老 abyss 喊 skill-manifest 报 unknown subcommand + const v = tryVersion(abyss); + if (!v || !v.version || compareVersions(v.version, SKILL_MANIFEST_AVAILABLE_FROM) < 0) { + return null; + } + } + try { + const r = spawnSync(abyss, ['skill-manifest', '--compact'], { encoding: 'utf8', timeout: timeoutMs }); + if (r.status !== 0 || !r.stdout) return null; + const m = JSON.parse(r.stdout); + if (!m || typeof m !== 'object' || m.schema_version !== 1) return null; + return m; + } catch { + return null; + } +} + +// 安装后摘要:把 manifest 浓缩成一行人类可读串。null in → null out(不打印)。 +function summarizeAbyssManifest(manifest) { + if (!manifest) return null; + const ver = manifest.version || '?'; + const cli = Array.isArray(manifest?.providers?.cli?.commands) ? manifest.providers.cli.commands.length : 0; + const mcp = Array.isArray(manifest?.providers?.mcp?.tools) ? manifest.providers.mcp.tools.length : 0; + const verbs = Array.isArray(manifest?.providers?.daemon?.verbs) ? manifest.providers.daemon.verbs.join(', ') : null; + let s = `abyss v${ver}: ${cli} CLI commands, ${mcp} MCP tools`; + if (verbs) s += `; daemon verbs: ${verbs}`; + return s; +} + +// MCP 工具源真:能从 manifest 拿就拿,否则 fallback 到硬编码列表(保留旧行为) +function resolveAbyssMcpTools(manifest, fallback = ['search_context', 'get_symbols', 'find_callers', 'impact_analysis', 'code_map', 'evolution', 'index_project']) { + const t = manifest && manifest.providers && manifest.providers.mcp && manifest.providers.mcp.tools; + if (Array.isArray(t) && t.length > 0) return t; + return fallback; +} + // ── MCP 注册(--with-mcp 显式 opt-in;8 个 tool schema 常驻 context 有成本) ── function buildMcpEntry(binPath) { @@ -215,6 +279,7 @@ function injectGeminiMcp(settings, binPath) { module.exports = { MIN_ABYSS_VERSION, + SKILL_MANIFEST_AVAILABLE_FROM, HOOK_MARKER, resolveAbyssHookDir, abyssManagedBinPath, @@ -229,4 +294,7 @@ module.exports = { buildMcpEntry, injectClaudeMcp, injectGeminiMcp, + tryReadAbyssManifest, + summarizeAbyssManifest, + resolveAbyssMcpTools, }; diff --git a/package.json b/package.json index 7fb1cff..87bedbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-abyss", - "version": "4.7.2", + "version": "4.8.0", "description": "为 Claude Code / Codex CLI / Gemini CLI / OpenClaw 注入可切换人格、主动执行导向、5种输出风格与30个工程技能(含自我进化炼炉 + 代码关系图智能)", "keywords": [ "claude", diff --git a/test/abyss-integration.test.js b/test/abyss-integration.test.js index fc0dd7f..4f33261 100644 --- a/test/abyss-integration.test.js +++ b/test/abyss-integration.test.js @@ -19,6 +19,10 @@ const { injectGeminiMcp, injectClaudeMcp, MIN_ABYSS_VERSION, + SKILL_MANIFEST_AVAILABLE_FROM, + tryReadAbyssManifest, + summarizeAbyssManifest, + resolveAbyssMcpTools, } = require('../bin/lib/abyss-integration.js'); const { @@ -160,12 +164,23 @@ describe('版本契约', () => { expect(compareVersions('1.0.0', '0.9.9')).toBeGreaterThan(0); }); - test('satisfiesMin 拒绝缺失与过旧', () => { - expect(satisfiesMin('0.3.1', MIN_ABYSS_VERSION)).toBe(true); - expect(satisfiesMin('0.2.0', MIN_ABYSS_VERSION)).toBe(false); + test('satisfiesMin 拒绝缺失与过旧(针对当前 MIN_ABYSS_VERSION 锁)', () => { + // MIN_ABYSS_VERSION = 0.5.20 起步;0.5.x 起算稳定,0.5.22+ 解锁 skill-manifest + expect(satisfiesMin('0.5.20', MIN_ABYSS_VERSION)).toBe(true); + expect(satisfiesMin('0.5.23', MIN_ABYSS_VERSION)).toBe(true); + expect(satisfiesMin('0.5.19', MIN_ABYSS_VERSION)).toBe(false); + expect(satisfiesMin('0.3.1', MIN_ABYSS_VERSION)).toBe(false); expect(satisfiesMin(null, MIN_ABYSS_VERSION)).toBe(false); }); + test('SKILL_MANIFEST_AVAILABLE_FROM 在 MIN_ABYSS_VERSION 之上', () => { + // 0.5.22+ 才有 skill-manifest;MIN 是 0.5.20 是允许的, + // 但 0.5.20 / 0.5.21 不应被错误地认为「能跑 skill-manifest」 + expect(compareVersions(SKILL_MANIFEST_AVAILABLE_FROM, MIN_ABYSS_VERSION)).toBeGreaterThanOrEqual(0); + expect(satisfiesMin('0.5.21', SKILL_MANIFEST_AVAILABLE_FROM)).toBe(false); + expect(satisfiesMin('0.5.22', SKILL_MANIFEST_AVAILABLE_FROM)).toBe(true); + }); + test('checkLockToolRequirement 覆盖缺失/过旧/满足/无声明', () => { const lock = { tools: { abyss: '>=0.3.1' } }; expect(checkLockToolRequirement(lock, null)).toMatchObject({ ok: false, reason: 'missing' }); @@ -293,3 +308,97 @@ describe('hook 目录解析', () => { expect(dir).toContain(HOOK_MARKER.split('/')[0]); }); }); + +describe('skill-manifest 能力发现', () => { + // 锁一个空 HOME 目录,避免误命中宿主机上真的 ~/.code-abyss/bin/abyss + function emptyHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'abyss-manifest-test-')); + } + + test('abyss 不在 PATH 且 ~/.code-abyss/bin 也没有时返回 null', () => { + const HOME = emptyHome(); + try { + const pathBefore = process.env.PATH; + process.env.PATH = HOME; // 一个绝对没 abyss 的目录 + try { + // 走 detectAbyss → null 路径 + expect(tryReadAbyssManifest({ HOME })).toBeNull(); + } finally { + process.env.PATH = pathBefore; + } + } finally { + fs.rmSync(HOME, { recursive: true, force: true }); + } + }); + + test('显式 binPath 指向不存在的文件返回 null(不抛)', () => { + expect(tryReadAbyssManifest({ binPath: '/no/such/abyss-bin-xyz' })).toBeNull(); + }); + + // 真实 abyss 二进制烟囱测试。CI 上没装就 skip,本地有就跑—— + // ABYSS_BIN 显式指向也认。 + const realAbyss = process.env.ABYSS_BIN + || (fs.existsSync('/home/telagod/project/code-abyss-dev/target/release/abyss') + ? '/home/telagod/project/code-abyss-dev/target/release/abyss' + : null); + const maybeIt = realAbyss ? test : test.skip; + + maybeIt('对真实 abyss ≥ 0.5.22 返回 schema_version=1 的 manifest', () => { + const m = tryReadAbyssManifest({ binPath: realAbyss }); + expect(m).toBeTruthy(); + expect(m.schema_version).toBe(1); + expect(typeof m.version).toBe('string'); + expect(m.providers).toBeTruthy(); + expect(Array.isArray(m.providers.cli.commands)).toBe(true); + expect(Array.isArray(m.providers.mcp.tools)).toBe(true); + expect(Array.isArray(m.providers.daemon.verbs)).toBe(true); + // 关键 verb:subscribe 是 0.5.22+ 新引入的,验证发现路径走通 + expect(m.providers.daemon.verbs).toContain('subscribe'); + }); + + test('summarizeAbyssManifest 把 manifest 浓缩成单行', () => { + expect(summarizeAbyssManifest(null)).toBeNull(); + const fake = { + version: '0.5.23', + schema_version: 1, + providers: { + cli: { commands: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] }, + mcp: { tools: ['x', 'y'] }, + daemon: { verbs: ['ping', 'subscribe'] }, + }, + }; + const s = summarizeAbyssManifest(fake); + expect(s).toContain('abyss v0.5.23'); + expect(s).toContain('3 CLI commands'); + expect(s).toContain('2 MCP tools'); + expect(s).toContain('daemon verbs: ping, subscribe'); + }); + + test('resolveAbyssMcpTools 优先用 manifest 否则 fallback', () => { + // null manifest → fallback + expect(resolveAbyssMcpTools(null)).toEqual(expect.arrayContaining(['search_context', 'get_symbols'])); + // 空数组也算无效,走 fallback + expect(resolveAbyssMcpTools({ providers: { mcp: { tools: [] } } }, ['fb'])).toEqual(['fb']); + // 有内容直接用 + expect(resolveAbyssMcpTools({ providers: { mcp: { tools: ['t1', 't2'] } } }, ['fb'])).toEqual(['t1', 't2']); + }); + + test('schema_version 不为 1 的 manifest 被拒(jest 单测 mock spawnSync)', () => { + jest.resetModules(); + jest.doMock('child_process', () => ({ + spawnSync: (cmd, args) => { + if (args && args[0] === '--version') { + return { status: 0, stdout: 'abyss 0.5.23\n' }; + } + if (args && args[0] === 'skill-manifest') { + return { status: 0, stdout: JSON.stringify({ schema_version: 2, version: '0.5.23' }) }; + } + return { status: 1, stdout: '' }; + }, + })); + const { tryReadAbyssManifest: mocked } = require('../bin/lib/abyss-integration.js'); + expect(mocked({ binPath: '/fake/abyss' })).toBeNull(); + jest.dontMock('child_process'); + jest.resetModules(); + }); +});