Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
72 changes: 70 additions & 2 deletions bin/lib/abyss-integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -215,6 +279,7 @@ function injectGeminiMcp(settings, binPath) {

module.exports = {
MIN_ABYSS_VERSION,
SKILL_MANIFEST_AVAILABLE_FROM,
HOOK_MARKER,
resolveAbyssHookDir,
abyssManagedBinPath,
Expand All @@ -229,4 +294,7 @@ module.exports = {
buildMcpEntry,
injectClaudeMcp,
injectGeminiMcp,
tryReadAbyssManifest,
summarizeAbyssManifest,
resolveAbyssMcpTools,
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
115 changes: 112 additions & 3 deletions test/abyss-integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const {
injectGeminiMcp,
injectClaudeMcp,
MIN_ABYSS_VERSION,
SKILL_MANIFEST_AVAILABLE_FROM,
tryReadAbyssManifest,
summarizeAbyssManifest,
resolveAbyssMcpTools,
} = require('../bin/lib/abyss-integration.js');

const {
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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();
});
});