@kienntpixon/dieu-phoi 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/README.md +20 -5
- package/agents/dp-exec.md +2 -0
- package/agents/dp-helper.md +1 -0
- package/agents/dp-scout.md +2 -0
- package/agents/dp-verify.md +3 -0
- package/bin/dieu-phoi.cjs +29 -4
- package/hook.cjs +675 -8
- package/lib/args.cjs +19 -2
- package/lib/cbm.cjs +490 -0
- package/lib/commands.cjs +149 -26
- package/lib/knowledge.cjs +1002 -0
- package/lib/profile.cjs +204 -0
- package/lib/prune.cjs +57 -20
- package/package.json +1 -1
package/lib/args.cjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
const core = require('../hook.cjs');
|
|
5
6
|
|
|
6
|
-
const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve', 'yes']);
|
|
7
|
+
const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve', 'yes', 'force', 'follow', 'remove', 'dirty', 'no-cache', 'untagged', 'no-knowledge', 'net', 'with-codebase-memory', 'cbm', 'allow-custom-source']);
|
|
7
8
|
|
|
8
9
|
/** Minimal argv parser: positionals, --k v, --k=v, boolean flags. */
|
|
9
10
|
function parseArgs(argv) {
|
|
@@ -52,4 +53,20 @@ function yesNo(v, dflt) {
|
|
|
52
53
|
throw new Error(`Giá trị y/n không hợp lệ: ${v}`);
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Repo a command works on: `--repo <path>` (any path inside the repo or one of its worktrees), else the cwd.
|
|
58
|
+
* Throws a readable error when --repo is not an existing directory. Does not require the path to be a git repo.
|
|
59
|
+
*/
|
|
60
|
+
function repoOf(opts) {
|
|
61
|
+
if (opts && opts.repo !== undefined) {
|
|
62
|
+
if (typeof opts.repo !== 'string' || !opts.repo) throw new Error('--repo cần một đường dẫn');
|
|
63
|
+
const p = path.resolve(core.nativePath(opts.repo));
|
|
64
|
+
let ok = false;
|
|
65
|
+
try { ok = fs.statSync(p).isDirectory(); } catch (_) { /* missing */ }
|
|
66
|
+
if (!ok) throw new Error(`--repo: không phải thư mục: ${p}`);
|
|
67
|
+
return p;
|
|
68
|
+
}
|
|
69
|
+
return process.cwd();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { parseArgs, claudeDirOf, ctxOf, yesNo, repoOf };
|
package/lib/cbm.cjs
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// codebase-memory-mcp (https://github.com/DeusData/codebase-memory-mcp): a recommended, optional MCP server that
|
|
4
|
+
// gives agents a code graph. `doctor` reports on it (installed? registered? version? a daemon of another version?
|
|
5
|
+
// repo indexed?) and `setup --with-codebase-memory` installs the official release binary + registers it in Claude
|
|
6
|
+
// Code at user scope. Nothing here is required for dieu-phoi to work: every problem is a warning, never an error.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const cp = require('child_process');
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
|
|
14
|
+
const REPO = 'DeusData/codebase-memory-mcp';
|
|
15
|
+
const SERVER_NAME = 'codebase-memory-mcp';
|
|
16
|
+
const RELEASE_BASE = `https://github.com/${REPO}/releases/latest/download`;
|
|
17
|
+
const LATEST_API = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
18
|
+
const INSTALL_HINT = 'dieu-phoi setup --with-codebase-memory';
|
|
19
|
+
const CHECKSUMS_MAX = 1024 * 1024;
|
|
20
|
+
const DAEMON_CACHE_MS = 10 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
const binName = (platform) => ((platform || process.platform) === 'win32' ? `${SERVER_NAME}.exe` : SERVER_NAME);
|
|
23
|
+
const toSlash = (p) => String(p).replace(/\\/g, '/');
|
|
24
|
+
|
|
25
|
+
/** Claude Code's own config file: `$CLAUDE_CONFIG_DIR/.claude.json`, or `~/.claude.json` for the default ~/.claude. */
|
|
26
|
+
function claudeJsonPath(claudeDir) {
|
|
27
|
+
const def = path.join(os.homedir(), '.claude');
|
|
28
|
+
if (path.resolve(claudeDir) === path.resolve(def)) return path.join(os.homedir(), '.claude.json');
|
|
29
|
+
return path.join(claudeDir, '.claude.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isCbmEntry(name, cfg) {
|
|
33
|
+
if (/codebase-memory/i.test(name)) return true;
|
|
34
|
+
return !!(cfg && typeof cfg.command === 'string' && /codebase-memory-mcp(\.exe)?$/i.test(cfg.command.trim()));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readJson(file) {
|
|
38
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function samePath(a, b) {
|
|
42
|
+
const n = (p) => toSlash(path.resolve(p)).replace(/\/+$/, '');
|
|
43
|
+
return process.platform === 'win32' ? n(a).toLowerCase() === n(b).toLowerCase() : n(a) === n(b);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** MCP registrations of codebase-memory-mcp visible to Claude Code for `repo`: user, local (per project) and project (.mcp.json). */
|
|
47
|
+
function registrations(claudeDir, repo) {
|
|
48
|
+
const out = [];
|
|
49
|
+
const file = claudeJsonPath(claudeDir);
|
|
50
|
+
const j = readJson(file);
|
|
51
|
+
const push = (scope, servers, src) => {
|
|
52
|
+
if (!servers || typeof servers !== 'object') return;
|
|
53
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
54
|
+
if (!isCbmEntry(name, cfg)) continue;
|
|
55
|
+
const c = cfg && typeof cfg === 'object' ? cfg : {};
|
|
56
|
+
// args/env are kept (never printed) so `install --force` can restore the entry if re-registering fails
|
|
57
|
+
out.push({
|
|
58
|
+
scope, name, command: typeof c.command === 'string' ? c.command : null, file: src,
|
|
59
|
+
args: Array.isArray(c.args) ? c.args.map(String) : [],
|
|
60
|
+
env: c.env && typeof c.env === 'object' && !Array.isArray(c.env) ? c.env : {},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
if (j) {
|
|
65
|
+
push('user', j.mcpServers, file);
|
|
66
|
+
if (repo && j.projects && typeof j.projects === 'object') {
|
|
67
|
+
for (const [p, pc] of Object.entries(j.projects)) {
|
|
68
|
+
if (pc && samePath(p, repo)) push('local', pc.mcpServers, file);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (repo) {
|
|
73
|
+
const root = repoRoot(repo) || repo;
|
|
74
|
+
const mj = readJson(path.join(root, '.mcp.json'));
|
|
75
|
+
if (mj) push('project', mj.mcpServers, path.join(root, '.mcp.json'));
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isFile(p) {
|
|
81
|
+
try { return fs.statSync(p).isFile(); } catch (_) { return false; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function defaultInstallDir(platform, env) {
|
|
85
|
+
const e = env || process.env;
|
|
86
|
+
if ((platform || process.platform) === 'win32') {
|
|
87
|
+
const base = e.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
88
|
+
return path.join(base, 'Programs', SERVER_NAME); // where the project's own install.ps1 puts it
|
|
89
|
+
}
|
|
90
|
+
return path.join(os.homedir(), '.local', 'bin');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Locate the binary: DIEU_PHOI_CBM_BIN (a path; `none` turns off PATH/default-dir search, for tests), the command of a
|
|
95
|
+
* Claude Code registration, PATH, then the default install dirs (~/.local/bin, %LOCALAPPDATA%/Programs/...).
|
|
96
|
+
*/
|
|
97
|
+
function findBinary(regs, env) {
|
|
98
|
+
const e = env || process.env;
|
|
99
|
+
const forced = e.DIEU_PHOI_CBM_BIN;
|
|
100
|
+
if (forced && forced !== 'none') return isFile(forced) ? { path: forced, source: 'DIEU_PHOI_CBM_BIN' } : null;
|
|
101
|
+
for (const r of regs || []) {
|
|
102
|
+
if (r.command && isFile(r.command)) return { path: r.command, source: `MCP ${r.scope}` };
|
|
103
|
+
}
|
|
104
|
+
if (forced === 'none') return null;
|
|
105
|
+
const name = binName();
|
|
106
|
+
for (const dir of String(e.PATH || e.Path || '').split(path.delimiter)) {
|
|
107
|
+
if (dir && isFile(path.join(dir, name))) return { path: path.join(dir, name), source: 'PATH' };
|
|
108
|
+
}
|
|
109
|
+
for (const dir of [path.join(os.homedir(), '.local', 'bin'), defaultInstallDir(process.platform, e)]) {
|
|
110
|
+
if (isFile(path.join(dir, name))) return { path: path.join(dir, name), source: 'thư mục mặc định' };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** argv for running `bin` (a .js/.cjs path runs under this node — lets tests use a fake binary on every OS). */
|
|
116
|
+
function binArgv(bin, args) {
|
|
117
|
+
return /\.c?js$/i.test(bin) ? [process.execPath, [bin, ...args]] : [bin, args];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function probeVersion(bin, timeoutMs) {
|
|
121
|
+
const [cmd, args] = binArgv(bin, ['--version']);
|
|
122
|
+
const r = cp.spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutMs || 3000, windowsHide: true });
|
|
123
|
+
if (r.error || r.status !== 0) return null;
|
|
124
|
+
const m = /(\d+\.\d+\.\d+)/.exec(`${r.stdout || ''}`);
|
|
125
|
+
return m ? m[1] : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `codebase-memory-mcp daemon status` → { state: active|none|unresponsive|unknown, version, pid, clients }. */
|
|
129
|
+
function parseDaemonStatus(text) {
|
|
130
|
+
const t = String(text || '');
|
|
131
|
+
const res = { state: 'unknown', version: null, pid: null, clients: [] };
|
|
132
|
+
if (/daemon:\s*active/i.test(t)) res.state = 'active';
|
|
133
|
+
else if (/not responding/i.test(t)) res.state = 'unresponsive';
|
|
134
|
+
else if (/not running|no daemon|inactive|stopped/i.test(t)) res.state = 'none';
|
|
135
|
+
const b = /build:\s*v?(\d+\.\d+\.\d+)/i.exec(t) || /active version\s*v?(\d+\.\d+\.\d+)/i.exec(t);
|
|
136
|
+
if (b) res.version = b[1];
|
|
137
|
+
const p = /^\s*pid:\s*(\d+)/im.exec(t) || /held by pid\s*(\d+)/i.exec(t);
|
|
138
|
+
if (p) res.pid = Number(p[1]);
|
|
139
|
+
const re = /^\s*-\s*pid\s+(\d+)/gim;
|
|
140
|
+
let m;
|
|
141
|
+
while ((m = re.exec(t))) res.clients.push(Number(m[1]));
|
|
142
|
+
return res;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Async so doctor can start it first and overlap it with its other (blocking) checks. */
|
|
146
|
+
function daemonStatus(bin, timeoutMs) {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
const [cmd, args] = binArgv(bin, ['daemon', 'status']);
|
|
149
|
+
cp.execFile(cmd, args, { encoding: 'utf8', timeout: timeoutMs || 5000, windowsHide: true }, (err, stdout, stderr) => {
|
|
150
|
+
if (err && err.killed) return resolve({ state: 'timeout', version: null, pid: null, clients: [] });
|
|
151
|
+
resolve(parseDaemonStatus(`${stdout || ''}\n${stderr || ''}`));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function cacheDir(env) {
|
|
157
|
+
const e = env || process.env;
|
|
158
|
+
return e.CBM_CACHE_DIR ? path.resolve(e.CBM_CACHE_DIR) : path.join(os.homedir(), '.cache', SERVER_NAME);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Top of the git work tree containing `dir` (a .git dir or file), or null. */
|
|
162
|
+
function repoRoot(dir) {
|
|
163
|
+
let d = path.resolve(dir);
|
|
164
|
+
for (;;) {
|
|
165
|
+
if (fs.existsSync(path.join(d, '.git'))) return d;
|
|
166
|
+
const up = path.dirname(d);
|
|
167
|
+
if (up === d) return null;
|
|
168
|
+
d = up;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** For a linked worktree (`.git` is a file), the main checkout's root; else null. */
|
|
173
|
+
function mainCheckoutOf(root) {
|
|
174
|
+
try {
|
|
175
|
+
const g = path.join(root, '.git');
|
|
176
|
+
if (!fs.statSync(g).isFile()) return null;
|
|
177
|
+
const m = /gitdir:\s*(.+)/.exec(fs.readFileSync(g, 'utf8'));
|
|
178
|
+
if (!m) return null;
|
|
179
|
+
const gitdir = path.resolve(root, m[1].trim());
|
|
180
|
+
let common = gitdir;
|
|
181
|
+
try { common = path.resolve(gitdir, fs.readFileSync(path.join(gitdir, 'commondir'), 'utf8').trim()); } catch (_) { /* none */ }
|
|
182
|
+
return path.basename(common) === '.git' ? path.dirname(common) : null;
|
|
183
|
+
} catch (_) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const canon = (s) => toSlash(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Is `repo` indexed? codebase-memory-mcp keeps one `<project>.db` per repo in its cache dir, the project name being the
|
|
192
|
+
* path with separators as `-` (D:/Projects/x → D-Projects-x). Compared loosely (case, punctuation). Best effort: no
|
|
193
|
+
* process is spawned (a `cli list_projects` takes ~13 s and fails while a daemon of another version runs).
|
|
194
|
+
*/
|
|
195
|
+
function indexStatus(repo, env) {
|
|
196
|
+
const root = repoRoot(repo);
|
|
197
|
+
if (!root) return { root: null, indexed: false, skipped: 'ngoài git' };
|
|
198
|
+
let names = [];
|
|
199
|
+
const dir = cacheDir(env);
|
|
200
|
+
try { names = fs.readdirSync(dir).filter((n) => /\.db$/.test(n) && !n.startsWith('_')).map((n) => n.slice(0, -3)); } catch (_) { /* none */ }
|
|
201
|
+
const want = (p) => names.find((n) => canon(n) === canon(p));
|
|
202
|
+
const hit = want(root);
|
|
203
|
+
if (hit) return { root, indexed: true, project: hit, dir };
|
|
204
|
+
const main = mainCheckoutOf(root);
|
|
205
|
+
const mainHit = main ? want(main) : null;
|
|
206
|
+
return { root, indexed: false, main, main_project: mainHit || null, dir };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function cmpVersion(a, b) {
|
|
210
|
+
const pa = String(a).replace(/^v/, '').split('.').map(Number);
|
|
211
|
+
const pb = String(b).replace(/^v/, '').split('.').map(Number);
|
|
212
|
+
for (let i = 0; i < 3; i++) {
|
|
213
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
214
|
+
if (d) return d < 0 ? -1 : 1;
|
|
215
|
+
}
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function latestVersion(env, timeoutMs) {
|
|
220
|
+
const url = (env || process.env).DIEU_PHOI_CBM_LATEST_URL || LATEST_API;
|
|
221
|
+
const res = await fetch(url, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'dieu-phoi' }, signal: AbortSignal.timeout(timeoutMs || 6000) });
|
|
222
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
223
|
+
const j = await res.json();
|
|
224
|
+
const m = /(\d+\.\d+\.\d+)/.exec(String(j && j.tag_name || ''));
|
|
225
|
+
if (!m) throw new Error('không đọc được tag_name');
|
|
226
|
+
return m[1];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function indexCommand(bin, root) {
|
|
230
|
+
return `${bin ? `"${toSlash(bin)}"` : SERVER_NAME} cli index_repository '{"repo_path":"${toSlash(root)}"}'`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function registerCommand(bin) {
|
|
234
|
+
return `claude mcp add --scope user ${SERVER_NAME} -- "${toSlash(bin)}"`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readDaemonCache(file, bin, version, nowMs) {
|
|
238
|
+
if (!file) return null;
|
|
239
|
+
const c = readJson(file);
|
|
240
|
+
if (!c || !c.daemon || c.bin !== toSlash(bin) || c.version !== (version || null)) return null;
|
|
241
|
+
const t = Date.parse(c.ts);
|
|
242
|
+
if (!t || nowMs - t > DAEMON_CACHE_MS || t > nowMs + 60000) return null;
|
|
243
|
+
if (!c.daemon.state || c.daemon.state === 'timeout') return null;
|
|
244
|
+
return { ts: c.ts, daemon: c.daemon };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function writeDaemonCache(file, bin, version, daemon, nowMs) {
|
|
248
|
+
if (!file || !daemon || daemon.state === 'timeout') return;
|
|
249
|
+
try {
|
|
250
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
251
|
+
fs.writeFileSync(file, `${JSON.stringify({ ts: new Date(nowMs).toISOString(), bin: toSlash(bin), version: version || null, daemon })}\n`);
|
|
252
|
+
} catch (_) { /* cache is best effort */ }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Doctor checks (all warnings at worst). Spawns at most `--version` (~40 ms) and, only with `deep` (doctor `--net` /
|
|
257
|
+
* `--cbm`), `daemon status` (~2.7 s, async, so it overlaps the other checks); otherwise the daemon line comes from a
|
|
258
|
+
* 10-minute cache (`cacheFile`) or says it was not checked. `net` adds one GitHub API call (6 s timeout).
|
|
259
|
+
* Returns a Promise of the checks: call it early, await it at the end.
|
|
260
|
+
*/
|
|
261
|
+
function doctorChecks({ claudeDir, repo, net, deep, cacheFile, env, now }) {
|
|
262
|
+
const e = env || process.env;
|
|
263
|
+
const nowMs = typeof now === 'number' ? now : Date.now();
|
|
264
|
+
const checks = [];
|
|
265
|
+
const add = (ok, name, detail) => checks.push({ ok, level: ok ? 'OK' : 'CẢNH BÁO', name, detail });
|
|
266
|
+
const regs = registrations(claudeDir, repo);
|
|
267
|
+
const found = findBinary(regs, e);
|
|
268
|
+
const regTxt = regs.length ? regs.map((r) => `${r.scope}:${r.name}`).join(', ') : null;
|
|
269
|
+
if (!found) {
|
|
270
|
+
add(false, 'codebase-memory', regs.length
|
|
271
|
+
? `MCP đã đăng ký (${regTxt}) nhưng không thấy binary ${regs.map((r) => r.command).filter(Boolean).join(', ') || ''} — cài lại: \`${INSTALL_HINT} --force\``
|
|
272
|
+
: `chưa cài (khuyến nghị, không bắt buộc: agent dùng Grep/Glob khi thiếu) — cài: \`${INSTALL_HINT}\``);
|
|
273
|
+
return Promise.resolve(checks);
|
|
274
|
+
}
|
|
275
|
+
const version = probeVersion(found.path);
|
|
276
|
+
const wantDaemon = !!(deep || net);
|
|
277
|
+
const cached = wantDaemon ? null : readDaemonCache(cacheFile, found.path, version, nowMs);
|
|
278
|
+
const daemonP = wantDaemon ? daemonStatus(found.path, 5000) : Promise.resolve(null);
|
|
279
|
+
const latestP = net ? latestVersion(e).then((v) => ({ v }), (err) => ({ err: err.message })) : Promise.resolve(null);
|
|
280
|
+
return (async () => {
|
|
281
|
+
add(!!version && regs.length > 0, 'codebase-memory', `${version ? `v${version}` : 'không chạy được `--version`'} tại ${toSlash(found.path)} (${found.source})${regs.length
|
|
282
|
+
? `; MCP: ${regTxt}` : ` — chưa đăng ký MCP trong Claude Code: \`${registerCommand(found.path)}\``}`);
|
|
283
|
+
const latest = await latestP;
|
|
284
|
+
if (latest && version) {
|
|
285
|
+
if (latest.err) add(true, 'cbm-version', `không kiểm được bản mới nhất (${latest.err})`);
|
|
286
|
+
else if (cmpVersion(version, latest.v) < 0) add(false, 'cbm-version', `v${version} cũ hơn bản mới nhất v${latest.v} — nâng cấp: \`${INSTALL_HINT} --force\` (đóng các phiên đang dùng MCP trước)`);
|
|
287
|
+
else add(true, 'cbm-version', `v${version} là bản mới nhất`);
|
|
288
|
+
}
|
|
289
|
+
let d = await daemonP;
|
|
290
|
+
let suffix = '';
|
|
291
|
+
if (d) writeDaemonCache(cacheFile, found.path, version, d, nowMs);
|
|
292
|
+
else if (cached) { d = cached.daemon; suffix = ` (bộ nhớ đệm lúc ${cached.ts}; \`dieu-phoi doctor --cbm\` để kiểm lại)`; }
|
|
293
|
+
if (!d) {
|
|
294
|
+
add(true, 'cbm-daemon', 'chưa kiểm (`daemon status` mất ~3 s) — chạy `dieu-phoi doctor --cbm` khi MCP báo lỗi hoặc nghi daemon khác version');
|
|
295
|
+
} else if (d.state === 'active' && d.version && version && d.version !== version) {
|
|
296
|
+
add(false, 'cbm-daemon', `daemon v${d.version} (pid ${d.pid || '?'}) đang chạy khác bản cài v${version} — bản cài sẽ bị từ chối (agent rơi về Grep/Glob) tới khi daemon cũ thoát. Daemon tự thoát khi phiên cuối đóng: đóng/khởi động lại các phiên Claude Code đang dùng MCP${d.clients.length ? ` (pid ${d.clients.join(', ')})` : ''}, hoặc \`codebase-memory-mcp daemon stop\` khi không còn phiên.${suffix}`);
|
|
297
|
+
} else if (d.state === 'unresponsive') {
|
|
298
|
+
add(false, 'cbm-daemon', `daemon không phản hồi${d.pid ? ` (endpoint do pid ${d.pid} giữ)` : ''} — có thể là bản khác; khởi động lại các phiên Claude Code đang dùng MCP${suffix}`);
|
|
299
|
+
} else if (d.state === 'active') {
|
|
300
|
+
add(true, 'cbm-daemon', `daemon v${d.version || '?'} đang chạy${d.clients.length ? `, ${d.clients.length} phiên` : ''}${suffix}`);
|
|
301
|
+
} else if (d.state === 'timeout') {
|
|
302
|
+
add(true, 'cbm-daemon', 'không đọc được `daemon status` trong 5 s (bỏ qua)');
|
|
303
|
+
} else {
|
|
304
|
+
add(true, 'cbm-daemon', `không có daemon đang chạy${suffix}`);
|
|
305
|
+
}
|
|
306
|
+
const ix = indexStatus(repo, e);
|
|
307
|
+
if (ix.root) {
|
|
308
|
+
if (ix.indexed) add(true, 'cbm-index', `${toSlash(ix.root)} đã index (project ${ix.project})`);
|
|
309
|
+
else {
|
|
310
|
+
add(false, 'cbm-index', `${toSlash(ix.root)} chưa index${ix.main_project ? ` (repo chính ${toSlash(ix.main)} đã có: ${ix.main_project})` : ''} — agent dùng Grep/Glob; index: \`${indexCommand(found.path, ix.root)}\``);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return checks;
|
|
314
|
+
})();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ------------------------------------------------------------------ install
|
|
318
|
+
|
|
319
|
+
/** Official release asset for this OS/arch (names as published in checksums.txt). */
|
|
320
|
+
function assetFor(platform, arch) {
|
|
321
|
+
const a = arch === 'arm64' ? 'arm64' : (arch === 'x64' ? 'amd64' : null);
|
|
322
|
+
if (!a) return null;
|
|
323
|
+
if (platform === 'win32') return `${SERVER_NAME}-windows-${a}.zip`;
|
|
324
|
+
if (platform === 'darwin') return `${SERVER_NAME}-darwin-${a}.tar.gz`;
|
|
325
|
+
if (platform === 'linux') return `${SERVER_NAME}-linux-${a}-portable.tar.gz`; // portable = no glibc version dependency
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function sha256File(file) {
|
|
330
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function digestFor(checksums, asset) {
|
|
334
|
+
for (const line of String(checksums).split(/\r?\n/)) {
|
|
335
|
+
const m = /^([0-9a-fA-F]{64})\s+\*?(\S+)\s*$/.exec(line.trim());
|
|
336
|
+
if (m && m[2] === asset) return m[1].toLowerCase();
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const defaultDeps = {
|
|
342
|
+
async fetchText(url) {
|
|
343
|
+
const res = await fetch(url, { redirect: 'follow', headers: { 'User-Agent': 'dieu-phoi' }, signal: AbortSignal.timeout(30000) });
|
|
344
|
+
if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`);
|
|
345
|
+
const len = Number(res.headers.get('content-length') || 0);
|
|
346
|
+
if (len > CHECKSUMS_MAX) throw new Error('checksums.txt vượt 1 MiB');
|
|
347
|
+
const t = await res.text();
|
|
348
|
+
if (t.length > CHECKSUMS_MAX) throw new Error('checksums.txt vượt 1 MiB');
|
|
349
|
+
return t;
|
|
350
|
+
},
|
|
351
|
+
async download(url, dest) {
|
|
352
|
+
const res = await fetch(url, { redirect: 'follow', headers: { 'User-Agent': 'dieu-phoi' }, signal: AbortSignal.timeout(600000) });
|
|
353
|
+
if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`);
|
|
354
|
+
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
|
|
355
|
+
},
|
|
356
|
+
extract(archive, destDir) {
|
|
357
|
+
// Windows' own bsdtar reads .zip; a git-bash GNU tar earlier on PATH would not.
|
|
358
|
+
const tar = process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : 'tar';
|
|
359
|
+
const r = cp.spawnSync(tar, ['-xf', archive, '-C', destDir], { encoding: 'utf8', timeout: 300000, windowsHide: true });
|
|
360
|
+
if (r.error || r.status !== 0) throw new Error(`giải nén lỗi: ${r.error ? r.error.message : (r.stderr || '').trim().slice(0, 200)}`);
|
|
361
|
+
},
|
|
362
|
+
probeVersion,
|
|
363
|
+
runClaude(args, env) {
|
|
364
|
+
const win = process.platform === 'win32';
|
|
365
|
+
const q = (a) => (win && /[\s"&|<>^]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a);
|
|
366
|
+
const r = cp.spawnSync(win ? ['claude', ...args.map(q)].join(' ') : 'claude', win ? [] : args, {
|
|
367
|
+
shell: win, encoding: 'utf8', timeout: 60000, windowsHide: true, env,
|
|
368
|
+
});
|
|
369
|
+
return { status: r.error ? null : r.status, out: `${r.stdout || ''}${r.stderr || ''}`.trim(), error: r.error ? r.error.message : null };
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
function tsStamp(d) {
|
|
374
|
+
return (d || new Date()).toISOString().replace(/[-:]/g, '').replace(/\..*/, '').replace('T', '-');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* `setup --with-codebase-memory`: install the official release binary (checksum-verified) and register it in Claude Code
|
|
379
|
+
* at user scope. An existing binary / registration is kept unless `force`; a replaced binary is renamed to
|
|
380
|
+
* `<bin>.bak-dieu-phoi-<ts>` first (rename works on Windows even while the old exe runs). `deps` is injectable for tests.
|
|
381
|
+
*/
|
|
382
|
+
async function install(opts, deps) {
|
|
383
|
+
const d = Object.assign({}, defaultDeps, deps || {});
|
|
384
|
+
const env = opts.env || process.env;
|
|
385
|
+
const platform = opts.platform || process.platform;
|
|
386
|
+
const arch = opts.arch || process.arch;
|
|
387
|
+
const claudeDir = opts.claudeDir;
|
|
388
|
+
const force = !!opts.force;
|
|
389
|
+
const res = { ok: true, binary: null, version: null, action: null, backup: null, mcp: null, notes: [] };
|
|
390
|
+
const regs = registrations(claudeDir, null).filter((r) => r.scope === 'user');
|
|
391
|
+
const existing = findBinary(regs, env);
|
|
392
|
+
|
|
393
|
+
let target;
|
|
394
|
+
if (existing && !force) {
|
|
395
|
+
res.binary = existing.path;
|
|
396
|
+
res.version = d.probeVersion(existing.path);
|
|
397
|
+
res.action = 'kept';
|
|
398
|
+
target = existing.path;
|
|
399
|
+
} else {
|
|
400
|
+
const asset = assetFor(platform, arch);
|
|
401
|
+
if (!asset) throw new Error(`không có bản phát hành cho ${platform}/${arch} — xem https://github.com/${REPO}/releases`);
|
|
402
|
+
const base = (env.DIEU_PHOI_CBM_BASE_URL || RELEASE_BASE).replace(/\/+$/, '');
|
|
403
|
+
if (base !== RELEASE_BASE) {
|
|
404
|
+
const warn = `DIEU_PHOI_CBM_BASE_URL đang đặt nguồn tải KHÁC bản phát hành chính thức (${base} thay vì ${RELEASE_BASE}); checksums.txt lấy cùng nguồn đó nên sha256 không còn bảo đảm đây là bản chính thức`;
|
|
405
|
+
if (!opts.allowCustomSource) throw new Error(`${warn}. Không tải gì. Nếu đúng ý (mirror nội bộ, thử nghiệm): thêm --allow-custom-source; không thì bỏ biến này.`);
|
|
406
|
+
res.notes.push(`CẢNH BÁO: ${warn} (đã xác nhận bằng --allow-custom-source)`);
|
|
407
|
+
}
|
|
408
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dieu-phoi-cbm-'));
|
|
409
|
+
try {
|
|
410
|
+
const sums = await d.fetchText(`${base}/checksums.txt`);
|
|
411
|
+
const want = digestFor(sums, asset);
|
|
412
|
+
if (!want) throw new Error(`checksums.txt không có ${asset}`);
|
|
413
|
+
const archive = path.join(tmp, asset);
|
|
414
|
+
await d.download(`${base}/${asset}`, archive);
|
|
415
|
+
const got = sha256File(archive);
|
|
416
|
+
if (got !== want) throw new Error(`sha256 không khớp cho ${asset}: tải về ${got}, checksums.txt ${want} — không cài`);
|
|
417
|
+
const out = path.join(tmp, 'x');
|
|
418
|
+
fs.mkdirSync(out);
|
|
419
|
+
d.extract(archive, out);
|
|
420
|
+
const name = binName(platform);
|
|
421
|
+
const fresh = path.join(out, name);
|
|
422
|
+
const st = fs.existsSync(fresh) ? fs.lstatSync(fresh) : null;
|
|
423
|
+
if (!st || !st.isFile()) throw new Error(`không thấy ${name} (tệp thường) trong ${asset}`);
|
|
424
|
+
const v = d.probeVersion(fresh);
|
|
425
|
+
if (!v) throw new Error(`binary vừa tải không chạy được \`--version\` — không cài`);
|
|
426
|
+
const dir = typeof opts.dir === 'string' ? path.resolve(opts.dir) : (existing ? path.dirname(existing.path) : defaultInstallDir(platform, env));
|
|
427
|
+
target = path.join(dir, name);
|
|
428
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
429
|
+
if (fs.existsSync(target)) {
|
|
430
|
+
res.backup = `${target}.bak-dieu-phoi-${tsStamp(d.now ? d.now() : new Date())}`;
|
|
431
|
+
fs.renameSync(target, res.backup);
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
fs.copyFileSync(fresh, target);
|
|
435
|
+
if (platform !== 'win32') fs.chmodSync(target, 0o755);
|
|
436
|
+
} catch (e) {
|
|
437
|
+
if (res.backup) { try { fs.renameSync(res.backup, target); res.backup = null; } catch (_) { /* keep backup */ } }
|
|
438
|
+
throw e;
|
|
439
|
+
}
|
|
440
|
+
res.binary = target;
|
|
441
|
+
res.version = v;
|
|
442
|
+
res.action = existing ? 'replaced' : 'installed';
|
|
443
|
+
if (existing && !samePath(existing.path, target)) res.notes.push(`bản cũ vẫn ở ${toSlash(existing.path)} (không đụng)`);
|
|
444
|
+
} finally {
|
|
445
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) { /* best effort */ }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// registration (user scope, the standard `claude mcp add`)
|
|
450
|
+
const cmdPath = toSlash(target);
|
|
451
|
+
const same = regs.find((r) => r.command && samePath(r.command, target));
|
|
452
|
+
if (regs.length && !force) {
|
|
453
|
+
res.mcp = same ? 'kept' : 'kept-other';
|
|
454
|
+
if (!same) res.notes.push(`MCP user đã đăng ký trỏ tới ${regs.map((r) => r.command).join(', ')} — giữ nguyên (dùng --force để trỏ về ${cmdPath})`);
|
|
455
|
+
} else {
|
|
456
|
+
const cenv = Object.assign({}, env);
|
|
457
|
+
if (path.resolve(claudeDir) !== path.resolve(path.join(os.homedir(), '.claude'))) cenv.CLAUDE_CONFIG_DIR = claudeDir;
|
|
458
|
+
// old entries are remembered (command/args/env) before `remove`, so a failed `add` can put them back
|
|
459
|
+
const removed = [];
|
|
460
|
+
for (const r of regs) {
|
|
461
|
+
const rm = d.runClaude(['mcp', 'remove', '--scope', 'user', r.name], cenv);
|
|
462
|
+
if (rm.status === 0) removed.push(r);
|
|
463
|
+
}
|
|
464
|
+
const r = d.runClaude(['mcp', 'add', '--scope', 'user', SERVER_NAME, '--', cmdPath], cenv);
|
|
465
|
+
if (r.status === 0) res.mcp = regs.length ? 'replaced' : 'added';
|
|
466
|
+
else {
|
|
467
|
+
res.mcp = 'manual';
|
|
468
|
+
res.notes.push(`không chạy được \`claude mcp add\` (${r.error || r.out || `exit ${r.status}`}) — tự chạy: ${registerCommand(cmdPath)}`);
|
|
469
|
+
const lost = [];
|
|
470
|
+
for (const o of removed) {
|
|
471
|
+
const argv = ['mcp', 'add'];
|
|
472
|
+
for (const [k, v] of Object.entries(o.env || {})) argv.push('--env', `${k}=${v}`);
|
|
473
|
+
argv.push('--scope', 'user', o.name, '--', o.command || '', ...(o.args || []));
|
|
474
|
+
if (o.command && d.runClaude(argv, cenv).status === 0) res.mcp = 'restored';
|
|
475
|
+
else lost.push(o);
|
|
476
|
+
}
|
|
477
|
+
if (removed.length && !lost.length) res.notes.push(`đã khôi phục đăng ký cũ (${removed.map((o) => o.name).join(', ')}), không mất gì`);
|
|
478
|
+
for (const o of lost) {
|
|
479
|
+
res.notes.push(`KHÔNG khôi phục được đăng ký cũ "${o.name}" (command: ${o.command ? toSlash(o.command) : '?'}${o.args && o.args.length ? `, ${o.args.length} args` : ''}${Object.keys(o.env || {}).length ? `, env: ${Object.keys(o.env).join(', ')}` : ''}); thêm lại bằng \`claude mcp add --scope user ${o.name} -- <command> <args>\` (giá trị env lấy từ bản sao .claude.json của bạn)`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return res;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
module.exports = {
|
|
487
|
+
REPO, SERVER_NAME, INSTALL_HINT, claudeJsonPath, registrations, findBinary, probeVersion, parseDaemonStatus, daemonStatus,
|
|
488
|
+
cacheDir, repoRoot, indexStatus, cmpVersion, latestVersion, doctorChecks, assetFor, digestFor, install, defaultInstallDir,
|
|
489
|
+
registerCommand, indexCommand,
|
|
490
|
+
};
|